Background
Follow-up from #381 (Outcome → EffectCandidate rename). That PR aligned naming but stopped short of the deeper architectural cleanup. Recap of the current mess and the target.
The mess today
MultiOutcomeEffect subclasses each have their own incompatible storage and accessor patterns:
| Subclass |
Internal storage |
Public .candidates returns |
Public .outcomes returns |
MultiOutcomeEffect (base) |
n/a — subclasses provide |
tuple[MutationEffect, ...] (raw) |
tuple[EffectCandidate, ...] (wrapped via _with_extra_outcomes) |
SpliceOutcomeSet |
self._splice_candidates (tuple[SpliceCandidate]) |
tuple[SpliceCandidate, ...] (a different type entirely) |
tuple[EffectCandidate, ...] (wraps SpliceCandidate fields into EffectCandidate.evidence) |
StructuralVariantEffect |
self._candidates + self._cryptic_candidates + self._splice_candidates |
raw tuple[MutationEffect, ...] from _candidates |
wrapped, unions the three lists |
PhaseCandidateSet |
self._candidates_raw + self._hypotheses |
sorted raw tuple |
wraps with phase metadata |
ExonicSpliceSite |
inline (self, alternate_effect) |
raw 2-tuple |
default wrap |
The class EffectCandidate is what's returned from .outcomes. Not from .candidates. That's the inversion that reads as messy: the type named EffectCandidate is not in the accessor named .candidates.
SpliceCandidate is a separate domain-specific dataclass that overlaps with EffectCandidate's purpose: both wrap an effect with metadata. SpliceCandidate's fields (outcome, plausibility, coding_effect, description, predicted_class_name, mutant_transcript) all fit cleanly into EffectCandidate's shape:
coding_effect (or placeholder) → EffectCandidate.effect
plausibility → EffectCandidate.probability
outcome → EffectCandidate.evidence["splice_outcome"]
mutant_transcript → effect.mutant_transcript (the inner Effect already has this slot)
predicted_class_name → recoverable from effect.__class__.__name__
description → optional evidence["description"], or dropped
So SpliceCandidate exists because of historical evolution, not architectural necessity.
Target
class EffectCandidate: # unchanged
effect: MutationEffect # the wrapped effect (real or placeholder)
source: str = \"varcode\"
probability: float | None
evidence: Mapping
class MultiOutcomeEffect:
@property
def candidates(self) -> tuple[EffectCandidate, ...]: ...
@property
def effects(self) -> tuple[MutationEffect, ...]:
return tuple(c.effect for c in self.candidates)
# Drop:
# MultiOutcomeEffect.outcomes (everywhere)
# MultiOutcomeEffect._with_extra_outcomes (rename to _with_extra_candidates)
# varcode.splice_outcomes.SpliceCandidate (entire class)
# SpliceOutcomeSet._outcome_effect (placeholder construction moves to build time)
Uniformity goal: every MultiOutcomeEffect subclass exposes the same shape (tuple[EffectCandidate, ...]). No special-case types, no special-case accessors.
Scope
Production code (varcode/)
Tests (~30 files)
Docs
CHANGELOG entry
Hard break, no deprecation alias. SpliceCandidate users need to read c.effect.X / c.probability / c.evidence[\"splice_outcome\"] instead of c.coding_effect / c.plausibility / c.outcome.
Acceptance
grep -rn '\.outcomes' varcode/ tests/ returns only references to the splice-outcome enum / module / splice_outcomes=True kwarg / observed_outcomes Protocol method. No effect.outcomes reads.
grep -rn 'SpliceCandidate' varcode/ tests/ returns zero.
- 1188+ tests pass.
References
Background
Follow-up from #381 (
Outcome→EffectCandidaterename). That PR aligned naming but stopped short of the deeper architectural cleanup. Recap of the current mess and the target.The mess today
MultiOutcomeEffectsubclasses each have their own incompatible storage and accessor patterns:.candidatesreturns.outcomesreturnsMultiOutcomeEffect(base)tuple[MutationEffect, ...](raw)tuple[EffectCandidate, ...](wrapped via_with_extra_outcomes)SpliceOutcomeSetself._splice_candidates(tuple[SpliceCandidate])tuple[SpliceCandidate, ...](a different type entirely)tuple[EffectCandidate, ...](wraps SpliceCandidate fields into EffectCandidate.evidence)StructuralVariantEffectself._candidates+self._cryptic_candidates+self._splice_candidatestuple[MutationEffect, ...]from_candidatesPhaseCandidateSetself._candidates_raw+self._hypothesesExonicSpliceSite(self, alternate_effect)The class
EffectCandidateis what's returned from.outcomes. Not from.candidates. That's the inversion that reads as messy: the type namedEffectCandidateis not in the accessor named.candidates.SpliceCandidateis a separate domain-specific dataclass that overlaps withEffectCandidate's purpose: both wrap an effect with metadata. SpliceCandidate's fields (outcome,plausibility,coding_effect,description,predicted_class_name,mutant_transcript) all fit cleanly into EffectCandidate's shape:coding_effect(or placeholder) →EffectCandidate.effectplausibility→EffectCandidate.probabilityoutcome→EffectCandidate.evidence["splice_outcome"]mutant_transcript→effect.mutant_transcript(the inner Effect already has this slot)predicted_class_name→ recoverable fromeffect.__class__.__name__description→ optionalevidence["description"], or droppedSo
SpliceCandidateexists because of historical evolution, not architectural necessity.Target
Uniformity goal: every
MultiOutcomeEffectsubclass exposes the same shape (tuple[EffectCandidate, ...]). No special-case types, no special-case accessors.Scope
Production code (
varcode/)MultiOutcomeEffect.outcomesproperty and_with_extra_outcomeshelper. Rename_extra_outcomesslot →_extra_candidates.MultiOutcomeEffect.effectsconvenience property.SpliceOutcomeSet: rewrite to storetuple[EffectCandidate, ...]directly. The_build_*_candidatehelpers insplice_outcomes.py(~10 sites) returnEffectCandidateinstead ofSpliceCandidate. Placeholder Effect (PredictedIntronRetention,PredictedCrypticSpliceSite,ExonLoss,Intronic) constructed at build time, not access time.mutant_transcriptattached to the innereffectinstance.SpliceCandidateclass entirely. Drop its export fromvarcode/__init__.py.StructuralVariantEffect: move the per-context wrapping logic fromoutcomesproperty into thecandidatesproperty. Subclass internal storage stays private.PhaseCandidateSet: same migration.most_likelyreturns inner Effect.ExonicSpliceSite:candidateswraps(self, alternate_effect)asEffectCandidateentries.HaplotypeEffect: trivial 1-candidate set; same migration.apply_rna_evidence_to_effects: write to_extra_candidates.observed_outcomesProtocol return type staysSequence[EffectCandidate](the wrapper is what's appended).apply_phase_resolver_to_effects: should already be fine; verify.Tests (~30 files)
effect.outcomesreads →effect.candidates.c.plausibility→c.probability,c.outcome→c.evidence[\"splice_outcome\"],c.coding_effect→c.effect,c.predicted_class_name→c.effect.__class__.__name__(or drop).SpliceCandidate(...)direct-construction tests (test_splice_candidate_is_frozen,test_splice_candidate_equality, etc.) — the class is gone.test_splice_outcome_set_json_round_tripetc.: serialization shape changes. Hard break documented.apply_rna_evidence_to_effectstests:_extra_outcomes→_extra_candidates.Docs
docs/germline.md: replace.outcomesreferences with.candidates. Document thatEffectCandidateis the uniform shape across multi-outcome effects.docs/api.md: dropSpliceCandidateentry; keepEffectCandidate. Document.candidatesand.effectsas the canonical accessors onMultiOutcomeEffect.outcomesreferences.CHANGELOG entry
Hard break, no deprecation alias.
SpliceCandidateusers need to readc.effect.X/c.probability/c.evidence[\"splice_outcome\"]instead ofc.coding_effect/c.plausibility/c.outcome.Acceptance
grep -rn '\.outcomes' varcode/ tests/returns only references to the splice-outcome enum / module /splice_outcomes=Truekwarg /observed_outcomesProtocol method. Noeffect.outcomesreads.grep -rn 'SpliceCandidate' varcode/ tests/returns zero.References
Outcome→EffectCandidaterename + description-field drop (merged). This issue is the deeper architectural cleanup that PR deferred.Outcomeintroduction.