PeptideContext + CandidateEpitope: multi-axis per-peptide model (Phase 1) - #283
Merged
Conversation
…peptide model
Introduces vaxrank/peptide_context.py as the replacement shape for the flat
EpitopePrediction. Two layers:
PeptideContext — peptide sequence + flanks + provenance + tuple of
mhctools.Prediction records (kind / predictor / allele).
CandidateEpitope — mutant PeptideContext + open-ended comparators dict
(wt today; nearest_self / nearest_vital_self /
nearest_nonCTA / nearest_oncovirus reserved for #254 /
#257 / #258).
Why: the flat EpitopePrediction can only hold one axis (affinity
percentile_rank), which is exactly the gap that #282 documents — the
2.25.0 coverage feature's "two-axis evidence" story is half-real because
presentation_percentile has nowhere to land. Generalizing to mhctools'
multi-kind Prediction record fixes that cleanly and gives us the shape
we need for the upcoming safety / homology comparators.
Disambiguation contract: best_for_kind(kind, *, predictor=None) raises
ValueError when multiple predictors emitted that kind and no predictor
arg is given — cross-predictor max(score) is meaningless because each
predictor's score scale is its own. Callers that want a per-predictor
loop iterate predictors_for_kind() explicitly.
Phase 1 only: types + tests. Migration of the existing call-sites
(epitope_io / epitope_logic / coverage / report) lands in follow-up PRs.
Refs: #282, #254, #257, #258, #261.
Renames best_for_kind → best, with two behavioral additions:
1. Kind aliases. mhcflurry / netmhcpan / pVACseq / LENS shorthand
resolves to canonical mhctools.Prediction.kind strings:
ba / affinity / binding → pMHC_affinity
el / presentation / elution → pMHC_presentation
stability / cleavage / proteasome / processing / ap / tap / erap
Matching is case-insensitive; canonical pMHC_* strings still work.
Same alias contract on predictors_for_kind / alleles_for /
versions_for so call sites can stay terse.
2. Version disambiguation. When the same predictor ran at multiple
versions (mhcflurry 2.1.0 + 2.1.1 both on file), best defaults
to the most recent by PEP 440 ordering rather than raising —
score scales are comparable across versions of the same
predictor, only freshness differs. Pass version= to pin
explicitly. Empty / non-PEP 440 strings sort below valid
versions so legacy data degrades cleanly. New versions_for
helper exposes the full set sorted oldest → newest.
The predictor-ambiguity contract is unchanged: multiple predictors
+ no predictor= still raises (cross-predictor max(score) is
meaningless because score scales differ per predictor).
Kind-named helpers (best_affinity / best_presentation / etc.) gain
a version= passthrough so the convenience accessors get the same
controls as best().
Tests: 45 cases covering alias resolution, single + multi-version
behavior, multi-predictor × multi-version, all-invalid version
fallback. 100% coverage on peptide_context.py.
Closed
6 tasks
Review fixes from #283: * best/best_*: add ``score_key`` parameter (Prediction -> float, higher wins). Default is the new ``default_score_key`` which uses Prediction.score; the score-normalization assumption is now in a named function with a CONTRACT docstring instead of buried in a hardcoded ``max(score)`` lambda. Override per call for "lowest %-rank wins" / "lowest IC50 wins" / etc. Higher-level pipelines drive ranking via the existing topiary DSL on row-frames (EpitopeConfig.score_expr); best() stays the per-record primitive. * predictions_by_kind_and_predictor: add a 4th nesting level for predictor_version. Previously, the same (kind, predictor, allele) scored at multiple versions silently collapsed (last-wins). The new shape keeps every record addressable. * _split_versions: shared helper extracted from _most_recent_version and versions_for so the parse-or-fallback logic isn't duplicated. * _KIND_ALIASES: wrap in MappingProxyType so test monkeypatching can't bleed across tests. * predictions: tuple[Prediction, ...]: TYPE_CHECKING-guarded annotation so static analyzers see the full type without importing mhctools at runtime (the module stays import-light). * requirements.txt: declare packaging>=21.0 (used by peptide_context for PEP 440 version ordering). Effectively always present transitively but worth pinning for minimal installs. Tests: +10 (53 total) for the new behaviors — score_key default + overrides + multi-predictor raise still applies under custom keys, 4-level nested view, version-axis collision-free, asdict round- trip serialization, flank-field carry. 99% coverage on peptide_context.py (1 line is the if TYPE_CHECKING: block). The migration of EpitopePrediction call-sites is tracked separately in #284 with a 5-stage staging plan.
…ele key Restructures the per-peptide store and reverts cosmetic adds that weren't pulling weight. Driven by review pushback in #283. Storage shape (was: flat tuple with on-demand nested view) ---------------------------------------------------------- predictions is now a *natively nested* dict: {kind: {predictor: {version: tuple[Prediction, ...]}}} The leaf tuple holds whatever Prediction records exist for that (kind, predictor, version) — alleles are *record properties*, not structural keys. That accommodates all three emission patterns: * proteasome_cleavage / antigen_processing: leaf has 1 record with allele="" * pMHC_affinity / pMHC_stability: leaf has N records (one per allele) * pMHC_presentation under mhctools' current emission: leaf has N records (mhcflurry-pres deconvolutes across alleles internally; mhctools calls it per-allele to capture each score — see mhctools/mhcflurry.py:246) The previous (kind, predictor, version, allele)-keyed nested view forced an empty-string key for processing kinds — wrong shape. Constructor accepts either nested form OR flat Sequence[Prediction] for producer ergonomics; __post_init__ groups via _group_predictions. predictions_flat() round-trips back out for serialization. Kind aliases (was: vaxrank-local _KIND_ALIASES) ----------------------------------------------- Defers to topiary.ranking._KIND_ALIASES — vaxrank no longer maintains its own alias table. topiary already covers ba / el / aff / ic50 / presentation / etc. Drops MappingProxyType wrap (moot once vaxrank isn't the dict's owner). Parametric scoring (was: score_key callable on best()) ------------------------------------------------------ score_key is gone. The parametric affordance is now predictions_for(kind, *, predictor=None, version=None) which returns the leaf tuple — callers rank on whatever axis they want (min by IC50, min by percentile_rank, full topiary DSL on a row-frame, …). best() is intentionally the common-case primitive only: max(score) with a documented contract that mhctools normalizes score so higher = better within a predictor. Conflating per-record selection with the higher-level EpitopeConfig.score_expr DSL (which operates on grouped row-frames) was the wrong layering. Other (review fallout) ---------------------- * requirements.txt: drop packaging>=21.0 (always present transitively; explicit pin was defensive overkill). * alleles_for: signature changed to alleles_for(kind, *, predictor=None, version=None) for consistency with predictions_for / best. * alleles_for filters out empty-string alleles so processing kinds return (). Tests ----- 46 cases. Pins: * Constructor auto-grouping (flat → nested) and pre-grouped passthrough. * Per-allele leaf accumulation; processing kinds with allele='' don't collapse. * predictions_flat() round-trips through the constructor. * Kind aliases resolve through topiary's table (parametrized over ba/aff/ic50/el/presentation/processing/etc.). * predictions_for raises on multi-predictor; auto-resolves multi-version. * Caller-driven ranking via predictions_for + min/max — pinned explicitly as *the* parametric-scoring affordance. * best for processing kind works without an allele. 99% coverage (1 line is the TYPE_CHECKING import block). Full suite: 799 passing.
* _load_kind_aliases: prefer topiary's public KIND_ALIASES, fall back to the legacy _KIND_ALIASES private name for older releases. Raises a clear ImportError if neither is present instead of letting an opaque AttributeError surface from deep inside PeptideContext construction. This is the seam for promoting topiary's public surface without touching every call site here — cross-package private-name imports are brittle. * PredictionStore type alias under TYPE_CHECKING: documents the precise nested shape (kind → predictor → version → tuple [Prediction]) so static analyzers carry it through. Field annotation moves from bare ``dict`` to the alias. * __post_init__: tighten ``isinstance(..., Sequence)`` check to ``isinstance(..., (list, tuple))``. Sequence matches str, which would crash with AttributeError on .kind for malformed input; the tuple-isinstance check rejects strings cleanly. Comment explains the ``object.__setattr__`` escape hatch for frozen- dataclass post_init writes. * versions_for docstring: clarify that legacy / unparseable strings come first, valid PEP 440 versions follow oldest → newest. That matches the leaf accessors defaulting to the *last* entry (most recent valid when any exist). * requirements.txt: pin packaging>=21.0. Used by peptide_context for PEP 440 version ordering. Always pulled in transitively via setuptools / pip, but explicit pinning keeps minimal / --no-deps installs from breaking at import time. 46 tests (existing) pass unchanged. Full suite: 798 passed.
* _load_kind_aliases: @functools.lru_cache(maxsize=1). The table
is module-constant once topiary loads, but ``_resolve_kind`` is
on the per-record hot path of ``best`` / ``predictions_for`` —
redoing the ``getattr`` chain on every call was waste. Removes
the no-cover pragma on the inner ImportError branch and adds
three tests for the load function: prefers public KIND_ALIASES,
falls back to _KIND_ALIASES, raises clear ImportError when both
are gone, and the cache actually caches.
* _split_versions + _most_recent_version → _sort_versions. Two
helpers were parse-splitting + slicing the result; collapsed to
one that returns the sorted list. Callers slice (``[-1]``) or
use the whole thing (``tuple(_sort_versions(...))``).
* predictions_for version branch: dropped the ``if len > 1 /
else next(iter())`` split — ``_sort_versions(by_version)[-1]``
works for both cases (sorting a 1-element collection is fine).
* predictions_for error message: ``%``-formatting → f-string.
* Six ``best_*`` helpers: dropped the ``predictor=`` /
``version=`` forwarding kwargs that no caller ever uses.
Compresses 24 lines to 6 one-liners. Callers wanting predictor
pinning use ``best('affinity', predictor=...)`` directly.
* Drop PredictionStore type alias. It was type-only (defined
under TYPE_CHECKING) so the runtime field annotation was just
the string "PredictionStore" — useless to ``get_type_hints``
and any introspection-based serializer. The shape is documented
in the module docstring; if/when introspection actually matters,
add a real runtime alias then.
* Drop wrong comment on ``CandidateEpitope.comparators``. It
claimed ``default_factory=dict`` was needed because the class
is frozen — that's not how ``default_factory`` works. Pure AI
confabulation.
* Trim over-explained comments on the ``predictions`` field and
``__post_init__``.
Net: 432 lines (was 483). Full suite: 801 passed.
* Drop the three ``# ----`` block-divider comments inside
``PeptideContext``. Editor outline views and method docstrings
already group the surface; the dividers were visual furniture.
One of them ("Flatten — for serialization …") delineated a
single method.
* Bare ``-> tuple`` return annotations → parameterized:
``kinds`` / ``predictors_for`` / ``versions_for`` /
``alleles_for`` return ``tuple[str, ...]``;
``predictions_flat`` returns ``tuple["Prediction", ...]`` to
match ``predictions_for``.
* Module docstring: "flat ``Sequence[Prediction]``" → "flat
``list``/``tuple`` of ``Prediction`` records" so the docs
match the runtime ``isinstance(..., (list, tuple))`` check
in ``__post_init__``.
No behavior change. 49 / 49 tests pass.
* alleles_for: don't raise when multiple predictors emitted ``kind`` and ``predictor=`` is unset. Alleles are a set property, not predictor-specific — score scales aren't involved, so the disambiguation rationale that applies to ``best`` / ``predictions_for`` doesn't apply here. Default behavior is now to union alleles across all predictors and versions; ``predictor=`` / ``version=`` still filter when callers want them. Surface area: any call site asking "what alleles do we have evidence for?" used to get a ValueError when both mhcflurry and netmhcpan ran (the typical multi-predictor setup from #261). Now it returns the obvious answer. * predictions_flat: sort output by ``(kind, predictor_name, predictor_version, allele)``. Previously the output order was producer-input order (whatever flat sequence the constructor saw), which made it non-deterministic across runs when the upstream produced records in different orders. Sorted output is stable for serialization round-trips and for diffing two runs' flattened predictions. Tests: +3 (52 total). alleles_for cross-predictor + cross- version union, predictions_flat order pinned via shuffled- input equivalence + explicit ordering check. Full suite: 804.
* predictions_flat: extend sort key from (kind, predictor, version, allele) to also include (score, value, percentile_rank). If a producer ever emits duplicate ``(kind, predictor, version, allele)`` records, the score / value / %-rank tail keeps order deterministic across input shuffle. The previous 4-key sort silently fell back on input order for duplicates. * alleles_for: validate explicit ``predictor=`` and ``version=`` arguments against what's actually present. A typo (``predictor= 'mhcflurr'``) used to silently return ``()``, masquerading as "no alleles" — now raises ``ValueError`` listing what IS available. Missing kind itself still returns ``()`` silently (asking about a kind we don't have isn't a typo). * versions_for(kind, predictor): predictor moves to keyword-only for consistency with ``alleles_for`` / ``predictions_for``. Phase 1 is types-only so no out-of-tree consumers exist yet. * _load_kind_aliases: wrap topiary's table in MappingProxyType before returning. A misbehaving caller mutating the returned dict would otherwise corrupt topiary's alias table for the rest of the session. Read-only view costs nothing. Tests: +4 (56 total). alleles_for raises on unknown predictor and unknown version, silent on missing kind; predictions_flat tie-breaker pinned via duplicate-(k,p,v,a) shuffle equivalence. Updated existing tests for versions_for kwarg + the MappingProxyType wrap. Full suite: 808.
mhctools sometimes emits NaN for ``percentile_rank`` (and occasionally for ``score`` / ``value`` when a predictor doesn't compute one). Python's ``sorted`` is undefined on NaN keys — NaN comparisons return False both ways — so a sorted output containing NaN-bearing entries had no determinism guarantee. Wrap each float key with ``_nan_safe(x) = (math.isnan(x), 0.0 if NaN else x)``. NaN-bearing entries sort *after* finite ones; within the NaN bucket they tie at ``(True, 0.0)`` and stable sort preserves preceding-key order, which is the best total order possible. Test: shuffled-input equivalence for a context where one record has ``percentile_rank=NaN``, sort doesn't crash, allele order matches.
This was referenced May 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces
vaxrank/peptide_context.pyas the replacement shape for the flatEpitopePrediction— Phase 1 ships the new types + tests; Phase 2 migration of existing call-sites is tracked in #284.Two layers:
PeptideContext— one peptide sequence + flanks + provenance + a tuple ofmhctools.Predictionrecords (kind / predictor / version / allele). Generic shape used for the antigenic candidate AND for any reference comparator.CandidateEpitope— one sliding-window position from aVaccinePeptide. Holds the mutantPeptideContextplus an open-endedcomparatorsdict ('wt'today;'nearest_self'/'nearest_vital_self'/'nearest_nonCTA'/'nearest_oncovirus'reserved for Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254 / CTA antigen database for on-target ligand classification (#249 follow-up) #257 / Oncovirus antigen database for on-target ligand classification (#249 follow-up) #258).Why
The flat
EpitopePredictiononly carries one axis (affinitypercentile_rank), which is exactly the gap #282 documents — the 2.25.0 coverage feature's "two-axis evidence" story is half-real becausepresentation_percentilehas nowhere to land. Generalizing to mhctools' multi-kindPredictionrecord fixes that cleanly and gives us the shape we need for the upcoming safety / homology comparators.API:
best(kind, *, predictor=None, version=None, score_key=None)The per-record primitive for "best prediction across alleles." Three orthogonal disambiguation levers:
Predictor disambiguation (hard-raise). Multiple predictors emit
kindandpredictor=is unset →ValueError. Cross-predictor ranking is meaningless because each predictor's score scale is its own. Callers that want a per-predictor loop iteratepredictors_for_kind(kind).Version disambiguation (auto-resolve). Multiple versions of the chosen predictor on file → defaults to most recent (PEP 440). Pass
version=to pin. Auto-resolves rather than raising because score scales are comparable across versions of the same predictor — only freshness differs. Empty / non-PEP 440 strings sort below valid versions, so legacy data degrades cleanly.Score selection (parametric).
score_keyis aPrediction → floatcallable (higher wins). Default isdefault_score_key(usesPrediction.score). Override per call:score_key=lambda p: -p.percentile_rankfor "lowest %-rank wins". Higher-level pipelines drive ranking via the topiary DSL on row-frames (EpitopeConfig.score_expr);best()is the per-record primitive and stays simple.Kind aliases
bestand the structured-view accessors (predictors_for_kind/alleles_for/versions_for) accept aliases (case-insensitive). mhcflurry / netmhcpan / pVACseq / LENS shorthand all resolve:ba/affinity/bindingpMHC_affinityel/presentation/elutionpMHC_presentationstabilitypMHC_stabilitycleavage/proteasomeproteasome_cleavageprocessing/apantigen_processingtaptap_transporteraperap_trimmingCanonical
pMHC_*strings still work unchanged.Scope of this PR
In: new module + 53 unit tests covering structured views (4-level nested, version-axis collision-free), kind/predictor/allele/version iteration, single-predictor best, multi-predictor disambiguation, version auto-resolve + explicit pin, kind aliases parametrized over all entries, kind-named helpers, parametric
score_keyoverride, asdict round-trip serialization, flank-field carry, comparator dict, frozen mutation context.100% effective coverage on
vaxrank/peptide_context.py(1 line uncovered:if TYPE_CHECKING:block — false at runtime by design).Out: migration of
epitope_io.py/epitope_logic.py/coverage.py/report.py/ranking.py/vaccine_peptide.py/vaccine_library.py/mrna.py/processing.py/core_logic.py/external_input.py/epitope_dsl.pycall-sites offEpitopePrediction. Tracked in #284 with a 5-stage staging plan so each consumer can migrate independently.Test plan
pytest tests/test_peptide_context.py— 53 tests pass./lint.sh— clean./test.sh— 806 passed, no regressionsRelated