You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Refined design. Supersedes the original proposal. Companion doc lives in-repo at docs/design/vaccine-antigen.md.
Related: #263 (surface non-SNV antigens), #257 (CTA database), #258 (oncovirus database), #254 (safety scoring).
1. The core reframing
Vaxrank's construct pipeline is built around MutantProteinFragment: a somatic
variant plus a mutant interval (mutant_amino_acid_start_offset … end_offset). The mutant interval is treated as "the part worth targeting."
Only somatic SNV/INDEL antigens become constructs; CTA / ERV / SPLICE / VIRAL
antigens are loaded, scored, and shown in the report, but never reach the
construct writers because they have no variant to place
(external_input.py: "non-coord rows are report-only — no genome placement").
Key observation. Targetability in vaxrank is already not defined by the
mutant interval. VaccinePeptide splits epitopes into target_epitopes / self_epitopes purely on occurs_in_reference — not on overlaps_mutation. The mutant interval is only a proxy: for a somatic
mutation, "k-mer absent from the reference proteome" ≈ "k-mer overlaps the
mutation," because the mutation is what makes the k-mer novel. The interval and
the self-screen agree, so we never had to distinguish them.
CTAs break the proxy. A CTA protein is a self protein — its k-mers are all in
the reference proteome — so the screen would reject every CTA k-mer even though
the CTA is a legitimate tumor-restricted target. The fix is not to abandon the
screen but to subtract the CTA's own genes from "self" before screening. The
same predicate then works.
This generalizes to a single definition:
Safely-targetable content of an antigen = the set of k-mers that are absent from the antigen-appropriate self-reference.
The only per-kind variation is which genes are subtracted from "self"
before building the reference index.
kind
genes subtracted from self-reference
why
mutation (SNV/INDEL/frameshift)
none
novelty is intrinsic to the mutation; WT k-mers stay in the reference and are correctly screened out
CTA
the full candidate-CTA universe (tsarina.CTA_unfiltered_gene_ids(); see §3.3–§3.4)
the CTA protein is self-sequence; tumor-specificity comes from expression, not sequence. A CTA k-mer that also occurs in a confidently-non-CTA gene is genuinely off-target — but uncertain CTA candidates are held out of the subtrahend, not counted as self
ERV
the ERV loci
same logic as CTA
viral
(subtract nothing human; reference is the whole human proteome)
a viral k-mer matching any human self-peptide is an autoimmunity risk
Under this model, overlaps_mutation demotes from a gate to a ranking
signal, and the mutant_amino_acid_* offsets become the mutation kind's
particular way of computing its targetable mask — one implementation of a
general interface, not a universal field.
This is the precise meaning of "move away from mutant interval to safely
targetable amino-acid content."
2. What is already scaffolded
The rails for this were laid deliberately and are waiting:
reference_proteome.py inlines CTA_GENE_IDS (from pirlygenes) and ReferenceProteome.from_genome(exclude_cta_genes=True) already builds the
CTA-excluded index — i.e. "all k-mers occurring in non-CTA genes."
CandidateEpitope carries occurs_in_non_CTA_reference, today aliased to occurs_in_reference (epitope_logic.py:296) with a comment: "this branch
will diverge for CTA-matching peptides" once the CTA set is populated.
VaccinePeptide's target/self split reads occurs_in_reference, with a
comment promising CTAs move to target_epitopes"without changing this
code" once consumers read the CTA-aware flag (vaccine_peptide.py:166-181).
predict_epitopes builds a plain ReferenceProteome(genome) and never the
antigen-appropriate (e.g. CTA-excluded) index.
Nothing flips the consumer (VaccinePeptide) onto the antigen-aware flag.
CTAs never reach predict_epitopes at all — they are report-only. This
is the bulk of the work and the reason a VaccineAntigen abstraction is
needed: a CTA has no varcode.Variant to flow through core_logic's
variant-keyed pipeline.
3. The VaccineAntigen abstraction
Decision (per design review): introduce VaccineAntigen as a wrapper above MutantProteinFragment, with MutantProteinFragment becoming the realization
of the mutation kind. The downstream construct machinery
(VaccinePeptide, ranking, slicing, mRNA/peptide assembly) operates on an antigen + its targetable mask, not on a variant.
VaccineAntigen
├── kind: "mutation" | "CTA" | "ERV" | "splice" | "viral"
├── gene_name: str
├── amino_acids: str # the protein/peptide context window
├── targetable_mask() -> Interval set # which residues are tumor-specific content
│ # mutation: the mutant span
│ # CTA/ERV/viral: the whole ORF
│ # splice: junction-spanning residues
├── self_reference_exclusions() -> set[gene_id] # genes subtracted from "self"
├── tumor_specificity: TumorSpecificityAttestation # REQUIRED for construct admission
├── provenance: <per-kind> # variant | cta_gene_id | erv_orf_id | splice_junction | viral_orf
└── (read-support / VAF fields, where applicable)
3.1 Targetability: a content layer + a two-tier safety screen
A k-mer at offset [s, e) is a candidate iff it overlaps targetable_mask()
(the tumor-specific content layer — mutant span for mutations, whole ORF for
CTA/ERV/viral). Candidates then pass through a two-tier safety screen, each
tier backed by a different index and answering a different question:
Tier 1 — broad self-exclusion (sequence-level, whole non-CTA proteome).
The exact k-mer must be absent from the self-reference built with self_reference_exclusions() subtracted (for CTA: the CTA-excluded proteome). A
peptide that occurs verbatim in any non-excluded self protein is not
tumor-specific. This is a hard target/self gate — cheap O(1) set membership,
already scaffolded as the exclude_cta_genes index. It catches off-target
self-presentation everywhere.
For mutation: self-reference = full proteome. WT k-mers are present and
correctly excluded; this coincides with "overlaps the mutation," which is why
today's code gets away with screening on this tier alone.
For CTA: a two-level determination. (a) Per-gene — which genes are CTAs
comes from tsarina (or pirlygenes). (b) Per-peptide — the targetable pool is
the set difference{k-mers of expressed-CTA proteins} \ {k-mers of confidently-non-CTA proteins}: a CTA peptide is admissible iff it occurs in an
expressed CTA protein and in no confidently-non-CTA protein. Crucially the
subtrahend is the complement of the full candidate-CTA universe, not the
complement of the clean set — uncertain CTA candidates are held out of both
sets (see §3.4). Peptides shared across CTA family members are kept —
every CTA in the family is tumor-restricted, so intra-family sharing is not an
off-target risk.
occurs_in_non_CTA_reference is the CTA-kind instance of a general occurs_in_self_reference(antigen) flag. VaccinePeptide's target/self split
reads that flag and stays source-agnostic — as the existing comment
promises.
Tier 2 — vital-tissue MHC-ligand scrutiny (presentation-level, observed,
HLA-restricted). A finer autoimmunity filter applied to whatever survives
Tier 1. Instead of the whole proteome, it consults observed MS-eluted ligands
from vital organs, and flags a candidate only when it (or a near-match) is a
peptide actually presented on essential tissue — not merely a substring of a
vital-organ protein — restricted to the patient's HLA alleles. A self-peptide
whose sequence exists in heart but is never eluted there carries little
autoimmunity risk; a peptide eluted from heart on the patient's HLA is a
serious one.
Backed by hitlist (observed
immunopeptidome: peptide × mhc_restriction × source-tissue flags) filtered to src_healthy_tissue and the vital-organ MS vocabulary from tsarina (SAFETY_TISSUE_GROUPS =
{brain, heart, lung, liver, pancreas}; _VITAL_TISSUE_MS_NAMES), intersected
with the patient's class-I alleles. The patient-HLA vital-tissue ligand set is
bounded (low thousands of peptides), so vaxrank loads it once and builds a
neighbor index — hitlist itself only does exact lookup, so all near-match logic
lives in vaxrank.
Disposition is a configurable rule ladder. Match strength has two orthogonal
knobs, not a fixed pair of cases:
position mask — which residues are compared: all (whole peptide), or tcr_facing (MHC-anchor positions excluded — see below), or an explicit
position list.
max distance — Hamming threshold over the masked positions.
Masked-Hamming comparison is per (allele, peptide-length) and only defined
between equal-length peptides. No length is assumed — class-I ligands run
8–11+, class-II 13–25, and lengths needn't match the candidate; the screen
compares within each length band and the mask is computed for whatever length is
in hand. Cross-length similarity, if ever wanted, is a separate length-agnostic
comparator (edit distance, no position mask), kept out of the default ladder.
These are nested, not parallel: TCR-facing differences are a sub-count of total
differences, so for a fixed distance k, {tcr_facing, ≤k} ⊇ {all, ≤k}.
Hardcoding "full Hamming ≤1 and TCR-facing Hamming ≤1" is therefore redundant —
the second already contains the first. Instead, expose a list of match rules,
each (mask, max_distance, action), evaluated strongest-first, first match
wins, with the matched rule recorded on the epitope:
action ∈ {hard_filter, penalty, flag}; penalty adds weight to the combined
score and sets vital_tissue_autoimmune_risk; flag annotates only. The nesting
makes the ladder well-ordered: an exact hit satisfies every rule and lands on hard_filter; a whole-peptide near-match that isn't exact falls to the {all, 1} penalty; an anchors-only difference falls through to the weaker {tcr_facing, 1} penalty. All matches are reported regardless of action.
Deriving the tcr_facing mask — from the predictor, never a fixed table.
Anchor positions are exactly the residues the MHC groove constrains, which is
precisely what the configured presentation predictor already models. So the mask
is computed per (allele, length) by positional sensitivity: hold a peptide
of that length, substitute each position across residues, and measure the change
in predicted presentation. High-sensitivity positions are anchors (MHC-facing);
low-sensitivity positions are TCR-facing. The result is cached per (predictor, allele, length).
This is correct by construction for the cases a fixed P2/PΩ table gets wrong:
Non-canonical anchors. Alleles that anchor at P1/P3/P5 (or use auxiliary
anchors) fall out of the sensitivity profile directly — nothing is hardcoded.
Non-9-mer / class II. The profile is computed at the actual length; for
class II it reflects the 9-residue binding core wherever it sits, not a fixed
index.
Non-human MHC. If the configured predictor supports the sample's allele
(mouse H-2, etc.), the same procedure applies; vaxrank's reference machinery is
already species-parameterized.
When the model can't be built — disable, don't guess. If the predictor
doesn't support an allele/length, or hitlist has no vital-tissue coverage for the
sample's species/HLA, the tcr_facing rules are skipped and the gap is
annotated (vital_tissue_coverage), rather than silently falling back to a
human-9-mer assumption. The all-mask rules (which need no anchor model) still
apply where ligand data exists. The comparator stays pluggable for non-Hamming
distances or an externally supplied anchor map.
The two tiers are intentionally asymmetric: Tier 1 is broad sequence presence
(any self protein, exact match, hard gate); Tier 2 is narrow observed
presentation (vital-organ eluted ligands, HLA-restricted, exact = hard filter /
near = penalty). Tier 1 answers "is this even tumor-specific?"; Tier 2 answers
"if we provoke a response, what does it hit on a vital organ?"
VaccineAntigen makes tumor specificity a first-class, required property.
An antigen is construct-eligible only if it carries kind-appropriate
evidence; otherwise it stays report-only (today's behavior for non-mutation
kinds).
kind
attestation
mutation
intrinsic — a somatic mutation absent from normal cells
CTA
gene in the POSITIVE set (CTA_gene_names() — tsarina-filtered and expressed). The restriction / restriction_confidence tiers (§3.4) are recorded as ranking + report signals, not a hard gate; hard safety is the per-peptide Tier-2 screen. UNKNOWN (never-expressed) and somatic-leak candidates are not admitted
ERV
tumor-specific ERV expression; silent in normal tissue
splice
a cause — spliceosome mutation (e.g. SF3B1) or splice-site mutation explaining the junction. Unexplained aberrant splicing is not admissible
viral
evidence the virus is tumor-carried, not systemic/diffuse
Principle (carried from PR #302): tumor-specificity evidence comes from the
input/annotation, never inferred. varcode validates provider annotations; it
does not supply missing data.
3.3 Data sources — consume the pirl-unc stack, don't re-curate
Two sibling packages already curate what this design needs; vaxrank should consume them rather than maintain its own lists.
tsarina (a.k.a. perseus) — curated
shared-antigen target selection. Provides the partitioned CTA universe
(CTA_unfiltered_* ⊇ CTA_filtered_* ⊇ CTA_gene_names()/CTA_gene_ids(),
with the gaps CTA_never_expressed_* and CTA_excluded_*; §3.4), the
HPA-antibody-adaptive evidence tiers (restriction × restriction_confidence
via CTA_by_axes(), CTA_evidence()), the viral proteomes
(ONCOGENIC_VIRUSES, viral_peptides(), human_exclusive_viral_peptides()),
the hotspot mutations (HOTSPOT_MUTATIONS, mutant_peptides()), and the vital-organ vocabulary (SAFETY_TISSUE_GROUPS, _VITAL_TISSUE_MS_NAMES).
It also already computes a per-CTA MS-restriction tier
(CANCER_ONLY → RECURRENT_HEALTHY) by wrapping hitlist — a gene-level
precursor of this design's per-peptide Tier 2.
hitlist — the observed MHC-ligand
MS dataset tsarina wraps. load_ms_observations(peptide=…, mhc_class=…) (exact
lookup), observations.parquet carrying peptide, mhc_restriction, and
source-tissue flags (src_cancer, src_healthy_tissue, src_healthy_reproductive, …), plus a peptide→gene mappings sidecar. This is
the Tier-2 ligand backend.
Supersession. vaxrank currently inlines CTA_GENE_IDS
(reference_proteome.py) and exclude_cta_genes — a single set that
approximates the clean CTA set. This design replaces it with two distinct
tsarina sets: CTA_unfiltered_gene_ids() for the negative subtrahend (what to
hold out of "self") and the tier-gated POSITIVE set
(CTA_gene_ids() × CTA_by_axes(...)) for the targetable pool (§3.4) —
keeping vendored snapshots only as an offline fallback. (The
[reference-pirlygenes] memory named pirlygenes as the CTA source; tsarina is
the newer, HPA-filtered, MS-joined successor and is what the user pointed at
here.)
Boundary. tsarina selects shared targets and scores them with public MS
evidence across population HLA panels; vaxrank designs constructs for one
patient. vaxrank pulls tsarina's curated sets + hitlist's raw observations and
applies them per-patient (patient HLA, patient tumor expression, patient
construct windows). Curation stays upstream; construct design stays in vaxrank.
3.4 CTA gene partition and expression-evidence tiers
The CTA candidate universe is not a clean binary. tsarina's gene_sets.py
partitions it into nested sets, and vaxrank maps them to three dispositions —
crucially including an abstain class that is in neither the positive nor the
negative set.
tsarina's nesting: CTA_unfiltered (full 358-gene universe — any gene called a
CTA by ≥1 source DB) ⊇ CTA_filtered (passes the HPA reproductive-restriction
filter) ⊇ CTA_gene_names() (filtered and expressed, ≥2 nTPM somewhere). The
two gaps are named functions:
disposition
tsarina set
definition
vaxrank treatment
POSITIVE (targetable pool)
CTA_gene_names() / CTA_gene_ids()
filtered and expressed
peptides enter the positive pool; admission graded by the tiers below
NEGATIVE held-out
CTA_excluded_* = unfiltered − filtered
candidate CTA that fails reproductive restriction (somatic-leak evidence)
neither — leaks into healthy tissue (not clean-targetable) but still a CTA candidate (not subtracted from real CTAs). Per tsarina's own docstring
UNKNOWN held-out
CTA_never_expressed_* = filtered − expressed
passes restriction filter but no protein data and max RNA < 2 nTPM
neither — no positive expression evidence (can't attest tumor-restriction → not targetable), but non-detection may be technical (no/poor antibody, low abundance, RNA detection floor), so we do not conclude it is normal self → not subtracted
The negative subtrahend (the proteins whose k-mers cancel candidate CTA
peptides) is therefore proteome \ CTA_unfiltered_gene_ids() — the complement of
the entire candidate universe, so that both held-out classes above are kept out
of the subtrahend. This is a correction to the naive "subtract everything except
the clean CTA set," which would let an unknown/leaky candidate's peptides wrongly
knock out a real target. vaxrank's inlined CTA_GENE_IDS is the wrong set to
subtract for this reason (it approximates the clean set, not the universe).
Expression-evidence tiers — soft signals, not an admission gate. tsarina
grades evidence quality by HPA antibody reliability, not a flat threshold: PROTEIN_SUPPORT_ORDER = Enhanced > Supported > Approved > Uncertain > Missing
(hpa.py), with an adaptive RNA-corroboration threshold — a better antibody
needs less RNA backup, a weaker one needs near-pure reproductive RNA:
HPA_ADAPTIVE_PROTEIN_RNA_THRESHOLDS = { # min deflated reproductive RNA fraction
Enhanced: 0.80, Supported: 0.90, Approved: 0.95, Uncertain: 0.98,
} # RNA "deflated" as max(0, nTPM - 1) to zero out basal noise before the ratio
These feed (with the per-modality protein / RNA / MS axes) into restriction ∈ {TESTIS, PLACENTAL, REPRODUCTIVE, SOMATIC} and restriction_confidence ∈ {HIGH, MODERATE, LOW}, queryable via CTA_by_axes(...). restriction_confidence
is a per-source averaged evidence score (synthesize_restriction): each modality
with data scores points — protein IHC +1.0 (+0.5 Enhanced/Supported), RNA +1.0 if
it agrees (+0.5 STRICT), MS +1.0 CANCER_ONLY/EXPECTED_TISSUE (+0.5
SINGLETON_HEALTHY, 0 RECURRENT_HEALTHY) — then score/n_sources: ≥1.2 → HIGH,
≥0.8 → MODERATE, else LOW. So MODERATE ≈ one solid source, uncorroborated.
Admission = POSITIVE membership; the tiers are not a hard gate. A CTA is
construct-eligible iff its gene is in CTA_gene_names() (already HPA-filtered +
expressed). restriction / restriction_confidence / ms_restriction are
recorded on the VaccineAntigen as ranking signals + report annotations, and
the hard safety work is done per-peptide by the Tier-2 vital-tissue ligand screen.
Why not a hard tier gate — a sanity check on canonical CTAs (2026-05-22) showed
a restriction ∈ {TESTIS,PLACENTAL} × confidence ≥ MODERATE gate would wrongly reject MAGE-A4 (REPRODUCTIVE — multi-reproductive-tissue expression; yet
afami-cel/Tecelra is FDA-approved), PRAME (LOW — some somatic RNA + only
singleton-healthy MS; yet a leading TCR target), and XAGE1A (reads SOMATIC,
mostly from missing protein data, despite CANCER_ONLY MS). The tiers describe public-evidence quality, not target validity, so they inform ranking — not
eligibility. The MS axis (ms_restriction: CANCER_ONLY … RECURRENT_HEALTHY) is
the gene-level precursor of the per-peptide Tier 2.
4. What carries over vs. what is new
Carries over (operates on (antigen, epitopes) once the fragment is
generalized): combined-score ranking, allele-coverage-aware selection, peptide +
mRNA construct assembly, the per-(peptide, allele) report.
New work:
VaccineAntigen model + per-kind targetable_mask() / self_reference_exclusions() / attestation.
predict_epitopes and the external-input loaders build the antigen-appropriate reference and populate occurs_in_self_reference
honestly (today's no-op alias diverges).
Flip VaccinePeptide onto the antigen-aware flag.
core_logic admits non-variant antigens (CTA first) into the
slice → VaccinePeptide path.
Plumb the evidence columns that drive the attestation gate.
Tier-2 screen: a VitalTissueLigandScreen backed by hitlist (load the
patient-HLA × vital-tissue eluted-ligand set once; build a neighbor index), the
configurable match-rule ladder (mask × max_hamming × action), the
predictor-derived tcr_facing mask per (allele, length), and the
exact→hard-filter / near→penalty disposition wired into admission + the
combined score.
Take tsarina + hitlist as (optional) dependencies; replace the inlined CTA_GENE_IDS with tsarina.CTA_gene_ids() (vendored snapshot as fallback).
5. Staged delivery
Designed so each stage ships independently and the abstraction lands before the
risky construct-admission change.
Antigen-aware self-reference (Tier 1, no behavior change). Build the
negative-subtrahend reference in predict_epitopes as proteome \ tsarina.CTA_unfiltered_gene_ids() (vendored universe snapshot as fallback) —
i.e. exclude the whole candidate universe, not just the clean set, so the
UNKNOWN/leaky classes (§3.4) are held out. Populate occurs_in_non_CTA_referencehonestly (diverges from occurs_in_reference
only for CTA-matching peptides). Note this corrects the current inlined CTA_GENE_IDS (clean-set approximation). No CTA antigens flow yet, so no
output change — but the flag becomes real and testable.
CTA admission (the vertical slice). Admit CTA antigens from the POSITIVE
set (CTA_gene_names()); UNKNOWN/leaky candidates stay report-only. Record restriction / restriction_confidence / ms_restriction on the antigen as
ranking + report signals (not a gate; §3.4). Flip VaccinePeptide onto occurs_in_self_reference. First kind to produce constructs. Output changes —
gated behind config / CLI opt-in, off by default (consistent with Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254's enabled: false).
Tier-2 vital-tissue ligand screen. Add the hitlist-backed VitalTissueLigandScreen + pluggable similarity comparator; wire exact→hard
filter and near→penalty into admission and the combined score. Independent of
stage 3's admission change but most useful once CTAs flow. Opt-in.
Generalize remaining kinds. ERV, splice, viral — each is a targetable_mask() + self_reference_exclusions() + attestation triple
(viral reuses tsarina's human_exclusive_viral_peptides()).
Retire the proxy. Demote overlaps_mutation to a documented ranking
signal; remove any remaining "interval == targetability" assumptions. Major
version bump.
6. Open questions
Self-reference granularity (Tier 1).Resolved (whole-family, three-set):
CTA genes are determined per-gene (tsarina/pirlygenes); the targetable pool is {k-mers of expressed-CTA proteins} \ {k-mers of confidently-non-CTA proteins},
where the subtrahend's complement is the full candidate universe
(CTA_unfiltered), so UNKNOWN (never-expressed) and somatic-leak candidates are
held out of both sets (§3.4). Family-shared peptides are kept. Requires
swapping the inlined CTA_GENE_IDS (clean-set approximation) for the universe.
UNKNOWN-candidate policy (§3.4). Held out by default (neither targeted nor
subtracted). Open: should a never-expressed candidate be optionally
promotable to POSITIVE when the patient's own tumor RNA shows expression —
turning "no public evidence" into "private evidence" for that patient — without
ever adding it to the subtrahend?
TCR-facing mask derivation (Tier 2). Resolved in favor of
predictor-derived positional sensitivity per (allele, length) (above), not a
fixed table. Residual: positional-perturbation cost (bounded, cached) vs.
reading a predictor's native motif/PWM where exposed (mhcflurry); and the
sensitivity cutoff that separates anchor from TCR-facing (top-N vs. threshold).
hitlist HLA/tissue coverage (Tier 2). When the patient's allele has thin or
no vital-tissue MS coverage in hitlist, Tier 2 is silently weak. Surface
coverage as a confidence annotation; decide whether to fall back to predicted
presentation over vital-tissue proteins (the alternative source we considered)
when observed coverage is absent.
Dependency optionality.tsarina / hitlist pull parquet data and (for
tsarina) HPA/pyensembl extras. Keep them optional extras
(vaxrank[shared-antigens]?), with the vendored CTA snapshot covering the
Tier-1 path when they're absent; Tier 2 simply disables without them.
CTA admission policy.Resolved: admission = POSITIVE membership
(CTA_gene_names()); the HPA-adaptive tiers (§3.4) are soft ranking/report
signals, not a hard gate (a 2026-05-22 sanity check showed a hard {TESTIS,PLACENTAL}×≥MODERATE gate wrongly drops MAGE-A4, PRAME, XAGE1A).
Hard per-peptide safety is Tier 2. Residual: how the tiers weight ranking, and
whether to optionally expose a hard tier filter for users who want one. ERV
expression thresholds still TBD; see Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254.
CTA target window — whole protein, or a windowed selection? With the
content layer = whole protein and the two-tier screen doing the trimming,
"whole protein, let the screen carve it" is the natural default.
1. The core reframing
Vaxrank's construct pipeline is built around
MutantProteinFragment: a somaticvariant plus a mutant interval (
mutant_amino_acid_start_offset…end_offset). The mutant interval is treated as "the part worth targeting."Only somatic SNV/INDEL antigens become constructs; CTA / ERV / SPLICE / VIRAL
antigens are loaded, scored, and shown in the report, but never reach the
construct writers because they have no variant to place
(
external_input.py: "non-coord rows are report-only — no genome placement").Key observation. Targetability in vaxrank is already not defined by the
mutant interval.
VaccinePeptidesplits epitopes intotarget_epitopes/self_epitopespurely onoccurs_in_reference— not onoverlaps_mutation. The mutant interval is only a proxy: for a somaticmutation, "k-mer absent from the reference proteome" ≈ "k-mer overlaps the
mutation," because the mutation is what makes the k-mer novel. The interval and
the self-screen agree, so we never had to distinguish them.
CTAs break the proxy. A CTA protein is a self protein — its k-mers are all in
the reference proteome — so the screen would reject every CTA k-mer even though
the CTA is a legitimate tumor-restricted target. The fix is not to abandon the
screen but to subtract the CTA's own genes from "self" before screening. The
same predicate then works.
This generalizes to a single definition:
tsarina.CTA_unfiltered_gene_ids(); see §3.3–§3.4)Under this model,
overlaps_mutationdemotes from a gate to a rankingsignal, and the
mutant_amino_acid_*offsets become the mutation kind'sparticular way of computing its targetable mask — one implementation of a
general interface, not a universal field.
This is the precise meaning of "move away from mutant interval to safely
targetable amino-acid content."
2. What is already scaffolded
The rails for this were laid deliberately and are waiting:
reference_proteome.pyinlinesCTA_GENE_IDS(from pirlygenes) andReferenceProteome.from_genome(exclude_cta_genes=True)already builds theCTA-excluded index — i.e. "all k-mers occurring in non-CTA genes."
CandidateEpitopecarriesoccurs_in_non_CTA_reference, today aliased tooccurs_in_reference(epitope_logic.py:296) with a comment: "this branchwill diverge for CTA-matching peptides" once the CTA set is populated.
VaccinePeptide's target/self split readsoccurs_in_reference, with acomment promising CTAs move to
target_epitopes"without changing thiscode" once consumers read the CTA-aware flag (
vaccine_peptide.py:166-181).CandidateEpitope.source_class∈ {mutation, viral, self} already exists.Three things are unbuilt:
predict_epitopesbuilds a plainReferenceProteome(genome)and never theantigen-appropriate (e.g. CTA-excluded) index.
VaccinePeptide) onto the antigen-aware flag.predict_epitopesat all — they are report-only. Thisis the bulk of the work and the reason a
VaccineAntigenabstraction isneeded: a CTA has no
varcode.Variantto flow throughcore_logic'svariant-keyed pipeline.
3. The
VaccineAntigenabstractionDecision (per design review): introduce
VaccineAntigenas a wrapper aboveMutantProteinFragment, withMutantProteinFragmentbecoming the realizationof the
mutationkind. The downstream construct machinery(
VaccinePeptide, ranking, slicing, mRNA/peptide assembly) operates on anantigen + its targetable mask, not on a variant.
3.1 Targetability: a content layer + a two-tier safety screen
A k-mer at offset
[s, e)is a candidate iff it overlapstargetable_mask()(the tumor-specific content layer — mutant span for mutations, whole ORF for
CTA/ERV/viral). Candidates then pass through a two-tier safety screen, each
tier backed by a different index and answering a different question:
Tier 1 — broad self-exclusion (sequence-level, whole non-CTA proteome).
The exact k-mer must be absent from the self-reference built with
self_reference_exclusions()subtracted (for CTA: the CTA-excluded proteome). Apeptide that occurs verbatim in any non-excluded self protein is not
tumor-specific. This is a hard target/self gate — cheap O(1) set membership,
already scaffolded as the
exclude_cta_genesindex. It catches off-targetself-presentation everywhere.
mutation: self-reference = full proteome. WT k-mers are present andcorrectly excluded; this coincides with "overlaps the mutation," which is why
today's code gets away with screening on this tier alone.
CTA: a two-level determination. (a) Per-gene — which genes are CTAscomes from tsarina (or pirlygenes). (b) Per-peptide — the targetable pool is
the set difference
{k-mers of expressed-CTA proteins} \ {k-mers of confidently-non-CTA proteins}: a CTA peptide is admissible iff it occurs in anexpressed CTA protein and in no confidently-non-CTA protein. Crucially the
subtrahend is the complement of the full candidate-CTA universe, not the
complement of the clean set — uncertain CTA candidates are held out of both
sets (see §3.4). Peptides shared across CTA family members are kept —
every CTA in the family is tumor-restricted, so intra-family sharing is not an
off-target risk.
occurs_in_non_CTA_referenceis theCTA-kind instance of a generaloccurs_in_self_reference(antigen)flag.VaccinePeptide's target/self splitreads that flag and stays source-agnostic — as the existing comment
promises.
Tier 2 — vital-tissue MHC-ligand scrutiny (presentation-level, observed,
HLA-restricted). A finer autoimmunity filter applied to whatever survives
Tier 1. Instead of the whole proteome, it consults observed MS-eluted ligands
from vital organs, and flags a candidate only when it (or a near-match) is a
peptide actually presented on essential tissue — not merely a substring of a
vital-organ protein — restricted to the patient's HLA alleles. A self-peptide
whose sequence exists in heart but is never eluted there carries little
autoimmunity risk; a peptide eluted from heart on the patient's HLA is a
serious one.
Backed by
hitlist(observedimmunopeptidome: peptide ×
mhc_restriction× source-tissue flags) filtered tosrc_healthy_tissueand the vital-organ MS vocabulary fromtsarina(SAFETY_TISSUE_GROUPS={brain, heart, lung, liver, pancreas};
_VITAL_TISSUE_MS_NAMES), intersectedwith the patient's class-I alleles. The patient-HLA vital-tissue ligand set is
bounded (low thousands of peptides), so vaxrank loads it once and builds a
neighbor index — hitlist itself only does exact lookup, so all near-match logic
lives in vaxrank.
Disposition is a configurable rule ladder. Match strength has two orthogonal
knobs, not a fixed pair of cases:
all(whole peptide), ortcr_facing(MHC-anchor positions excluded — see below), or an explicitposition list.
Masked-Hamming comparison is per
(allele, peptide-length)and only definedbetween equal-length peptides. No length is assumed — class-I ligands run
8–11+, class-II 13–25, and lengths needn't match the candidate; the screen
compares within each length band and the mask is computed for whatever length is
in hand. Cross-length similarity, if ever wanted, is a separate length-agnostic
comparator (edit distance, no position mask), kept out of the default ladder.
These are nested, not parallel: TCR-facing differences are a sub-count of total
differences, so for a fixed distance
k,{tcr_facing, ≤k}⊇{all, ≤k}.Hardcoding "full Hamming ≤1 and TCR-facing Hamming ≤1" is therefore redundant —
the second already contains the first. Instead, expose a list of match rules,
each
(mask, max_distance, action), evaluated strongest-first, first matchwins, with the matched rule recorded on the epitope:
action ∈ {hard_filter, penalty, flag};penaltyaddsweightto the combinedscore and sets
vital_tissue_autoimmune_risk;flagannotates only. The nestingmakes the ladder well-ordered: an exact hit satisfies every rule and lands on
hard_filter; a whole-peptide near-match that isn't exact falls to the{all, 1}penalty; an anchors-only difference falls through to the weaker{tcr_facing, 1}penalty. All matches are reported regardless of action.Deriving the
tcr_facingmask — from the predictor, never a fixed table.Anchor positions are exactly the residues the MHC groove constrains, which is
precisely what the configured presentation predictor already models. So the mask
is computed per
(allele, length)by positional sensitivity: hold a peptideof that length, substitute each position across residues, and measure the change
in predicted presentation. High-sensitivity positions are anchors (MHC-facing);
low-sensitivity positions are TCR-facing. The result is cached per
(predictor, allele, length).This is correct by construction for the cases a fixed P2/PΩ table gets wrong:
anchors) fall out of the sensitivity profile directly — nothing is hardcoded.
class II it reflects the 9-residue binding core wherever it sits, not a fixed
index.
(mouse H-2, etc.), the same procedure applies; vaxrank's reference machinery is
already species-parameterized.
When the model can't be built — disable, don't guess. If the predictor
doesn't support an allele/length, or hitlist has no vital-tissue coverage for the
sample's species/HLA, the
tcr_facingrules are skipped and the gap isannotated (
vital_tissue_coverage), rather than silently falling back to ahuman-9-mer assumption. The
all-mask rules (which need no anchor model) stillapply where ligand data exists. The comparator stays pluggable for non-Hamming
distances or an externally supplied anchor map.
The two tiers are intentionally asymmetric: Tier 1 is broad sequence presence
(any self protein, exact match, hard gate); Tier 2 is narrow observed
presentation (vital-organ eluted ligands, HLA-restricted, exact = hard filter /
near = penalty). Tier 1 answers "is this even tumor-specific?"; Tier 2 answers
"if we provoke a response, what does it hit on a vital organ?"
3.2 Tumor-specificity attestation (admission gate)
VaccineAntigenmakes tumor specificity a first-class, required property.An antigen is construct-eligible only if it carries kind-appropriate
evidence; otherwise it stays report-only (today's behavior for non-mutation
kinds).
CTA_gene_names()— tsarina-filtered and expressed). Therestriction/restriction_confidencetiers (§3.4) are recorded as ranking + report signals, not a hard gate; hard safety is the per-peptide Tier-2 screen. UNKNOWN (never-expressed) and somatic-leak candidates are not admittedPrinciple (carried from PR #302): tumor-specificity evidence comes from the
input/annotation, never inferred. varcode validates provider annotations; it
does not supply missing data.
3.3 Data sources — consume the pirl-unc stack, don't re-curate
Two sibling packages already curate what this design needs; vaxrank should
consume them rather than maintain its own lists.
tsarina(a.k.a. perseus) — curatedshared-antigen target selection. Provides the partitioned CTA universe
(
CTA_unfiltered_*⊇CTA_filtered_*⊇CTA_gene_names()/CTA_gene_ids(),with the gaps
CTA_never_expressed_*andCTA_excluded_*; §3.4), theHPA-antibody-adaptive evidence tiers (
restriction×restriction_confidencevia
CTA_by_axes(),CTA_evidence()), the viral proteomes(
ONCOGENIC_VIRUSES,viral_peptides(),human_exclusive_viral_peptides()),the hotspot mutations (
HOTSPOT_MUTATIONS,mutant_peptides()), and thevital-organ vocabulary (
SAFETY_TISSUE_GROUPS,_VITAL_TISSUE_MS_NAMES).It also already computes a per-CTA MS-restriction tier
(
CANCER_ONLY→RECURRENT_HEALTHY) by wrapping hitlist — a gene-levelprecursor of this design's per-peptide Tier 2.
hitlist— the observed MHC-ligandMS dataset tsarina wraps.
load_ms_observations(peptide=…, mhc_class=…)(exactlookup),
observations.parquetcarryingpeptide,mhc_restriction, andsource-tissue flags (
src_cancer,src_healthy_tissue,src_healthy_reproductive, …), plus a peptide→gene mappings sidecar. This isthe Tier-2 ligand backend.
Supersession. vaxrank currently inlines
CTA_GENE_IDS(
reference_proteome.py) andexclude_cta_genes— a single set thatapproximates the clean CTA set. This design replaces it with two distinct
tsarina sets:
CTA_unfiltered_gene_ids()for the negative subtrahend (what tohold out of "self") and the tier-gated POSITIVE set
(
CTA_gene_ids()×CTA_by_axes(...)) for the targetable pool (§3.4) —keeping vendored snapshots only as an offline fallback. (The
[
reference-pirlygenes] memory named pirlygenes as the CTA source; tsarina isthe newer, HPA-filtered, MS-joined successor and is what the user pointed at
here.)
Boundary. tsarina selects shared targets and scores them with public MS
evidence across population HLA panels; vaxrank designs constructs for one
patient. vaxrank pulls tsarina's curated sets + hitlist's raw observations and
applies them per-patient (patient HLA, patient tumor expression, patient
construct windows). Curation stays upstream; construct design stays in vaxrank.
3.4 CTA gene partition and expression-evidence tiers
The CTA candidate universe is not a clean binary. tsarina's
gene_sets.pypartitions it into nested sets, and vaxrank maps them to three dispositions —
crucially including an abstain class that is in neither the positive nor the
negative set.
tsarina's nesting:
CTA_unfiltered(full 358-gene universe — any gene called aCTA by ≥1 source DB) ⊇
CTA_filtered(passes the HPA reproductive-restrictionfilter) ⊇
CTA_gene_names()(filtered and expressed, ≥2 nTPM somewhere). Thetwo gaps are named functions:
CTA_gene_names()/CTA_gene_ids()CTA_excluded_*=unfiltered − filteredCTA_never_expressed_*=filtered − expressedThe negative subtrahend (the proteins whose k-mers cancel candidate CTA
peptides) is therefore
proteome \ CTA_unfiltered_gene_ids()— the complement ofthe entire candidate universe, so that both held-out classes above are kept out
of the subtrahend. This is a correction to the naive "subtract everything except
the clean CTA set," which would let an unknown/leaky candidate's peptides wrongly
knock out a real target. vaxrank's inlined
CTA_GENE_IDSis the wrong set tosubtract for this reason (it approximates the clean set, not the universe).
Expression-evidence tiers — soft signals, not an admission gate. tsarina
grades evidence quality by HPA antibody reliability, not a flat threshold:
PROTEIN_SUPPORT_ORDER=Enhanced > Supported > Approved > Uncertain > Missing(
hpa.py), with an adaptive RNA-corroboration threshold — a better antibodyneeds less RNA backup, a weaker one needs near-pure reproductive RNA:
These feed (with the per-modality protein / RNA / MS axes) into
restriction ∈ {TESTIS, PLACENTAL, REPRODUCTIVE, SOMATIC}andrestriction_confidence ∈ {HIGH, MODERATE, LOW}, queryable viaCTA_by_axes(...).restriction_confidenceis a per-source averaged evidence score (
synthesize_restriction): each modalitywith data scores points — protein IHC +1.0 (+0.5 Enhanced/Supported), RNA +1.0 if
it agrees (+0.5 STRICT), MS +1.0 CANCER_ONLY/EXPECTED_TISSUE (+0.5
SINGLETON_HEALTHY, 0 RECURRENT_HEALTHY) — then
score/n_sources: ≥1.2 → HIGH,≥0.8 → MODERATE, else LOW. So
MODERATE≈ one solid source, uncorroborated.Admission = POSITIVE membership; the tiers are not a hard gate. A CTA is
construct-eligible iff its gene is in
CTA_gene_names()(already HPA-filtered +expressed).
restriction/restriction_confidence/ms_restrictionarerecorded on the
VaccineAntigenas ranking signals + report annotations, andthe hard safety work is done per-peptide by the Tier-2 vital-tissue ligand screen.
Why not a hard tier gate — a sanity check on canonical CTAs (2026-05-22) showed
a
restriction ∈ {TESTIS,PLACENTAL}×confidence ≥ MODERATEgate would wronglyreject MAGE-A4 (REPRODUCTIVE — multi-reproductive-tissue expression; yet
afami-cel/Tecelra is FDA-approved), PRAME (LOW — some somatic RNA + only
singleton-healthy MS; yet a leading TCR target), and XAGE1A (reads SOMATIC,
mostly from missing protein data, despite CANCER_ONLY MS). The tiers describe
public-evidence quality, not target validity, so they inform ranking — not
eligibility. The MS axis (
ms_restriction:CANCER_ONLY … RECURRENT_HEALTHY) isthe gene-level precursor of the per-peptide Tier 2.
4. What carries over vs. what is new
Carries over (operates on
(antigen, epitopes)once the fragment isgeneralized): combined-score ranking, allele-coverage-aware selection, peptide +
mRNA construct assembly, the per-(peptide, allele) report.
New work:
VaccineAntigenmodel + per-kindtargetable_mask()/self_reference_exclusions()/ attestation.predict_epitopesand the external-input loaders build theantigen-appropriate reference and populate
occurs_in_self_referencehonestly (today's no-op alias diverges).
VaccinePeptideonto the antigen-aware flag.core_logicadmits non-variant antigens (CTA first) into theslice →
VaccinePeptidepath.VitalTissueLigandScreenbacked byhitlist(load thepatient-HLA × vital-tissue eluted-ligand set once; build a neighbor index), the
configurable match-rule ladder (
mask×max_hamming×action), thepredictor-derived
tcr_facingmask per(allele, length), and theexact→hard-filter / near→penalty disposition wired into admission + the
combined score.
tsarina+hitlistas (optional) dependencies; replace the inlinedCTA_GENE_IDSwithtsarina.CTA_gene_ids()(vendored snapshot as fallback).5. Staged delivery
Designed so each stage ships independently and the abstraction lands before the
risky construct-admission change.
negative-subtrahend reference in
predict_epitopesasproteome \ tsarina.CTA_unfiltered_gene_ids()(vendored universe snapshot as fallback) —i.e. exclude the whole candidate universe, not just the clean set, so the
UNKNOWN/leaky classes (§3.4) are held out. Populate
occurs_in_non_CTA_referencehonestly (diverges fromoccurs_in_referenceonly for CTA-matching peptides). Note this corrects the current inlined
CTA_GENE_IDS(clean-set approximation). No CTA antigens flow yet, so nooutput change — but the flag becomes real and testable.
VaccineAntigentype (types-only). Introduce the wrapper; reexpressMutantProteinFragmentas themutationkind behind it. Mirrors thePeptideContext arc's Phase-1 "types-only" pattern (PeptideContext + CandidateEpitope: multi-axis per-peptide model (Phase 1) #283). No producers
switched.
set (
CTA_gene_names()); UNKNOWN/leaky candidates stay report-only. Recordrestriction/restriction_confidence/ms_restrictionon the antigen asranking + report signals (not a gate; §3.4). Flip
VaccinePeptideontooccurs_in_self_reference. First kind to produce constructs. Output changes —gated behind config / CLI opt-in, off by default (consistent with Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254's
enabled: false).hitlist-backedVitalTissueLigandScreen+ pluggable similarity comparator; wire exact→hardfilter and near→penalty into admission and the combined score. Independent of
stage 3's admission change but most useful once CTAs flow. Opt-in.
targetable_mask()+self_reference_exclusions()+ attestation triple(viral reuses tsarina's
human_exclusive_viral_peptides()).overlaps_mutationto a documented rankingsignal; remove any remaining "interval == targetability" assumptions. Major
version bump.
6. Open questions
CTA genes are determined per-gene (tsarina/pirlygenes); the targetable pool is
{k-mers of expressed-CTA proteins} \ {k-mers of confidently-non-CTA proteins},where the subtrahend's complement is the full candidate universe
(
CTA_unfiltered), so UNKNOWN (never-expressed) and somatic-leak candidates areheld out of both sets (§3.4). Family-shared peptides are kept. Requires
swapping the inlined
CTA_GENE_IDS(clean-set approximation) for the universe.subtracted). Open: should a never-expressed candidate be optionally
promotable to POSITIVE when the patient's own tumor RNA shows expression —
turning "no public evidence" into "private evidence" for that patient — without
ever adding it to the subtrahend?
predictor-derived positional sensitivity per
(allele, length)(above), not afixed table. Residual: positional-perturbation cost (bounded, cached) vs.
reading a predictor's native motif/PWM where exposed (mhcflurry); and the
sensitivity cutoff that separates anchor from TCR-facing (top-N vs. threshold).
no vital-tissue MS coverage in hitlist, Tier 2 is silently weak. Surface
coverage as a confidence annotation; decide whether to fall back to predicted
presentation over vital-tissue proteins (the alternative source we considered)
when observed coverage is absent.
tsarina/hitlistpull parquet data and (fortsarina) HPA/pyensembl extras. Keep them optional extras
(
vaxrank[shared-antigens]?), with the vendored CTA snapshot covering theTier-1 path when they're absent; Tier 2 simply disables without them.
(
CTA_gene_names()); the HPA-adaptive tiers (§3.4) are soft ranking/reportsignals, not a hard gate (a 2026-05-22 sanity check showed a hard
{TESTIS,PLACENTAL}×≥MODERATEgate wrongly drops MAGE-A4, PRAME, XAGE1A).Hard per-peptide safety is Tier 2. Residual: how the tiers weight ranking, and
whether to optionally expose a hard tier filter for users who want one. ERV
expression thresholds still TBD; see Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254.
TBD; see Oncovirus antigen database for on-target ligand classification (#249 follow-up) #258.
content layer = whole protein and the two-tier screen doing the trimming,
"whole protein, let the screen carve it" is the natural default.
half (which residues are eligible) and folds in Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254's tissue-aware
cross-reactivity axis as Tier 2. Safety scoring of vaccine antigen windows: minimize self-peptide presentation + tissue-aware autoimmunity risk #254's interior self-presentation burden
(sliding non-mutant k-mers, dose-dilution scoring) remains a complementary
scoring axis on top. Shared reference/ligand backends; land compatibly.