Skip to content

PeptideContext + CandidateEpitope: multi-axis per-peptide model (Phase 1) - #283

Merged
iskandr merged 10 commits into
mainfrom
peptide-context
May 8, 2026
Merged

PeptideContext + CandidateEpitope: multi-axis per-peptide model (Phase 1)#283
iskandr merged 10 commits into
mainfrom
peptide-context

Conversation

@iskandr

@iskandr iskandr commented May 6, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces vaxrank/peptide_context.py as the replacement shape for the flat EpitopePrediction — Phase 1 ships the new types + tests; Phase 2 migration of existing call-sites is tracked in #284.

Two layers:

Why

The flat EpitopePrediction only carries one axis (affinity percentile_rank), which is exactly the gap #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.

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 kind and predictor= is unset → ValueError. Cross-predictor ranking is meaningless because each predictor's score scale is its own. Callers that want a per-predictor loop iterate predictors_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_key is a Prediction → float callable (higher wins). Default is default_score_key (uses Prediction.score). Override per call: score_key=lambda p: -p.percentile_rank for "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

best and the structured-view accessors (predictors_for_kind / alleles_for / versions_for) accept aliases (case-insensitive). mhcflurry / netmhcpan / pVACseq / LENS shorthand all resolve:

alias canonical
ba / affinity / binding pMHC_affinity
el / presentation / elution pMHC_presentation
stability pMHC_stability
cleavage / proteasome proteasome_cleavage
processing / ap antigen_processing
tap tap_transport
erap erap_trimming

Canonical 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_key override, 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.py call-sites off EpitopePrediction. 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 regressions

Related

iskandr added 2 commits May 6, 2026 13:56
…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.
iskandr added 2 commits May 6, 2026 14:58
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.
iskandr added 6 commits May 7, 2026 21:19
* _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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant