From 9917474d1d349e962d143775aadff6761fd7ac83 Mon Sep 17 00:00:00 2001
From: Alex Rubinsteyn
Date: Thu, 8 Oct 2026 13:17:42 -0400
Subject: [PATCH 1/2] Highlight target codons, vaccine disagreements, and RNA
support tiers
---
README.md | 21 +++
pipeline/build_site.py | 40 +++-
tests/test_vaccine_peptide_analysis.py | 19 ++
tests/test_web_peptides.js | 127 ++++++++++++-
tests/test_web_results.js | 32 ++++
web/app.js | 77 +++++++-
web/index.html | 14 +-
web/peptides.html | 39 +++-
web/peptides.js | 244 ++++++++++++++++++++++---
web/style.css | 18 ++
10 files changed, 587 insertions(+), 44 deletions(-)
diff --git a/README.md b/README.md
index 7852a3d..0b0c1f9 100644
--- a/README.md
+++ b/README.md
@@ -79,6 +79,27 @@ Benchmark-matched Sid counts and Exacto protein/RNA-only/no-call outcomes stay v
below the sequences; other source libraries are available in a separate disclosure.
Missing counts, zero coverage, and covered loci with zero mutant reads remain
different states. RNA allele support does not establish full peptide recovery.
+The report separates 2+ mutant-read evidence from single-read evidence, observed
+zero, and unknown counts. This uses the maximum Sid count in a benchmark-matched
+RNA library at any timepoint, never a sum across libraries or methods. The main
+page retains the overall score and shows recovery within each evidence group;
+amino-acid failures name the expected and observed residues inline.
+
+Blue underlines locate each translated target codon using the archived RNA-call
+position. Deletion markers mean the codon at/after the junction. A dotted tail
+means downstream of a frameshift, not confirmed novel sequence throughout; fusion
+breakpoints and novel-ORF boundaries are not annotated in this panel. Reference
+markers require an exact local anchor with an unambiguous projected position.
+Rose marks vaccine-versus-reconstruction differences on both rows, independently
+of the amber comparison against W1. Missing ends are coverage, not substitutions.
+Stars mark every RNA-support leader (including ties) per sample and method.
+Exacto itself emits unranked candidates; this report ranks displayed windows by
+distinct input RNAs without using vaccine agreement. The top-window filter finds
+covered-position disagreements, including leaders that contain another peptide;
+details distinguish incomplete, unaligned, and allele-unconfirmed sequences.
+The recorded CD109 peptide ends in KKK, which may be a synthetic modification but
+is not documented as such by its source. Full recorded sequences remain the
+scoring reference; no terminal residues are silently removed.
Its metric is **vaccine sequence contained in reconstruction**: the recorded
vaccine peptide must appear as a contiguous substring of a reconstructed protein,
which can extend on either side. Every counted occurrence is tied to an RNA call
diff --git a/pipeline/build_site.py b/pipeline/build_site.py
index cf1e8ce..c271e95 100644
--- a/pipeline/build_site.py
+++ b/pipeline/build_site.py
@@ -53,6 +53,36 @@ def current_peptide_report(catalogue: dict, results: dict | None) -> dict | None
return report
+def source_rna_evidence(variant: dict) -> list[dict]:
+ samples = {sample.bam_url.rsplit("/", 1)[-1]: sample.name for sample in SAMPLES}
+ return [
+ {**entry, "benchmark_sample": samples.get((entry.get("bam_file") or "").rsplit("/", 1)[-1])}
+ for entry in variant.get("assay_support", [])
+ if entry.get("tissue") == "tumor"
+ and assays.ASSAY_META.get(entry.get("assay_type"), {}).get("kind") == "rna"
+ ]
+
+
+def rna_support_tier(evidence: list[dict]) -> dict:
+ """Maximum observed mutant count, never pooled across libraries or methods."""
+ def classify(entries):
+ counts = [entry["alt_reads"] for entry in entries
+ if isinstance(entry.get("alt_reads"), int) and entry["alt_reads"] >= 0]
+ maximum = max(counts, default=None)
+ return {"max_alt_reads": maximum, "category": "unknown" if maximum is None else
+ "multiple" if maximum >= 2 else "single" if maximum == 1 else "zero"}
+
+ by_sample = {sample.name: classify([entry for entry in evidence
+ if entry.get("benchmark_sample") == sample.name])
+ for sample in SAMPLES}
+ return {
+ **classify([entry for entry in evidence if entry.get("benchmark_sample")]),
+ "by_sample": by_sample,
+ "missing_samples": [name for name, counts in by_sample.items() if counts["category"] == "unknown"],
+ "basis": "Sid mutant reads in benchmark-matched tumor RNA libraries; maximum, not sum",
+ }
+
+
def peptide_report_with_rna_evidence(report: dict, catalogue: dict, results: dict | None = None) -> dict:
"""Attach source counts without pooling libraries or inferring missing counts.
@@ -60,7 +90,6 @@ def peptide_report_with_rna_evidence(report: dict, catalogue: dict, results: dic
Match benchmark samples by the actual BAM basename, not timepoint alone.
"""
by_id = {variant["variant_id"]: variant for variant in catalogue["variants"]}
- samples = {sample.bam_url.rsplit("/", 1)[-1]: sample.name for sample in SAMPLES}
report = json.loads(json.dumps(report))
scored = {variant["variant_id"]: variant for variant in (results or {}).get("variants", [])}
for variant in report["variants"]:
@@ -73,12 +102,8 @@ def peptide_report_with_rna_evidence(report: dict, catalogue: dict, results: dic
for sample, values in scored.get(variant["variant_id"], {}).get("samples", {}).items()
for arm, entry in values.get("arms", {}).items()
]
- variant["source_rna_support"] = [
- {**entry, "benchmark_sample": samples.get((entry.get("bam_file") or "").rsplit("/", 1)[-1])}
- for entry in by_id[variant["variant_id"]].get("assay_support", [])
- if entry.get("tissue") == "tumor"
- and assays.ASSAY_META.get(entry.get("assay_type"), {}).get("kind") == "rna"
- ]
+ variant["source_rna_support"] = source_rna_evidence(by_id[variant["variant_id"]])
+ variant["rna_support"] = rna_support_tier(variant["source_rna_support"])
return report
@@ -286,6 +311,7 @@ def build_payload() -> dict:
"assay_matrix": assays.matrix(variant),
"germline_matrix": assays.germline(variant),
"recovery": recovery,
+ "rna_support": rna_support_tier(source_rna_evidence(variant)),
}
)
diff --git a/tests/test_vaccine_peptide_analysis.py b/tests/test_vaccine_peptide_analysis.py
index 3e9dcc4..195b01b 100644
--- a/tests/test_vaccine_peptide_analysis.py
+++ b/tests/test_vaccine_peptide_analysis.py
@@ -146,3 +146,22 @@ def test_source_rna_counts_match_the_actual_bam_and_do_not_pool_libraries():
assert evidence[0]["alt_reads"] == 4
assert evidence[1]["benchmark_sample"] is None
assert "source_rna_support" not in report["variants"][0]
+
+
+def test_rna_support_tiers_use_maximum_without_pooling_or_imputing_missing_samples():
+ rows = [
+ {"benchmark_sample": "T1-ONT", "alt_reads": 1},
+ {"benchmark_sample": "T2-ONT", "alt_reads": 1},
+ {"benchmark_sample": "T2-ONT", "alt_reads": 1},
+ {"benchmark_sample": None, "alt_reads": 100},
+ ]
+ tier = build_site.rna_support_tier(rows)
+ assert tier["category"] == "single"
+ assert tier["max_alt_reads"] == 1
+ assert tier["by_sample"]["T2-ONT"]["category"] == "single"
+ assert "T1-PacBio" in tier["missing_samples"]
+ rows.append({"benchmark_sample": "T3-ONT", "alt_reads": 2})
+ assert build_site.rna_support_tier(rows)["category"] == "multiple"
+ assert build_site.rna_support_tier([])["category"] == "unknown"
+ assert build_site.rna_support_tier([{"benchmark_sample": "T1-ONT", "alt_reads": None}])["category"] == "unknown"
+ assert build_site.rna_support_tier([{"benchmark_sample": "T1-ONT", "alt_reads": 0}])["category"] == "zero"
diff --git a/tests/test_web_peptides.js b/tests/test_web_peptides.js
index 89d2214..52fa5ac 100644
--- a/tests/test_web_peptides.js
+++ b/tests/test_web_peptides.js
@@ -3,7 +3,8 @@ const fs = require("node:fs");
const path = require("node:path");
const test = require("node:test");
const { sequenceRows, highlightedPieces, sharedOffset, referenceGroups,
- sequenceComparison, alignmentSlice, reconstructionWindows, windowDifferences, windowSlice, rnaSupport, sequenceStatus, sourceRnaState } = require("../web/peptides.js");
+ sequenceComparison, alignmentSlice, reconstructionWindows, windowDifferences, windowSlice, rnaSupport, sequenceStatus, sourceRnaState,
+ vaccineComparison, vaccineDifferences, windowEvents, referenceEvents, prepareComparisons, topWindows } = require("../web/peptides.js");
const reportPath = path.join(__dirname, "../results/vaccine_peptide_analysis.json");
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, "utf8")) : null;
@@ -338,3 +339,127 @@ test("RNA support counts input RNAs once per sample and method, without counting
assert.deepEqual(rnaSupport(data, hits, { sample: "T1-ONT", method: "reads" })[0].methods,
[{ method: "reads", count: 2 }]);
});
+
+function annotatedFixture() {
+ const sequence = 'SFSGPGMSGMALMEVNLLSGKKK';
+ const variant = { variant_id: 'CD109', gene: 'CD109', ref: 'G', alt: 'T', protein_change: 'p.Arg1310Met',
+ reconstructed_candidates: [], published_vaccine_peptides: [{ peptide_id: 'p1', sequence, in_vaccines: ['V3'], matches: [] }] };
+ const data = { analysis: { artifacts: [{ sample: 'T1', arm: 'reads' }, { sample: 'T1', arm: 'corrected' }] },
+ variants: [variant], reconstructions: {} };
+ function add(id, sequence, position, matched = false, arm = 'reads') {
+ const candidate = { sample: 'T1', arm, reconstruction_id: `${arm}/${id}`, protein_id: id,
+ rna_call_id: id, variant_amino_acid_start: position };
+ variant.reconstructed_candidates.push(candidate);
+ data.reconstructions[candidate.reconstruction_id] = sequence;
+ if (matched) variant.published_vaccine_peptides[0].matches.push({ ...candidate, amino_acid_starts: [1] });
+ }
+ return { data, variant, add, sequence };
+}
+
+test('CD109 vaccine mismatches highlight KKK versus FMV on both rows, even in W1', () => {
+ const { data, add } = annotatedFixture();
+ add('short|orf_0-59', 'MSGMALMEVNLLSGFMVPSEA', 1);
+ const rows = sequenceRows(data), [group] = prepareComparisons(data, rows);
+ const entry = group.references[0], window = group.windows[0];
+ const diff = vaccineComparison(window, entry);
+ assert.equal(diff.missing, 6); // absent prefix is coverage, not a substitution
+ assert.deepEqual(diff.substitutions.map(x => [x.position, x.reference, x.residue]),
+ [[21, 'K', 'F'], [22, 'K', 'M'], [23, 'K', 'V']]);
+ const highlights = vaccineDifferences(group);
+ assert.equal(highlights.windows.get(window).size, 3);
+ assert.deepEqual([...highlights.references.get(entry).keys()], [...highlights.windows.get(window).keys()]);
+ assert.equal(group.comparisons.get(window).substitutions.length, 0);
+ // A protein starting at the mutation has only four local anchor residues;
+ // this intentionally does not infer a vaccine-row target position.
+ assert.equal(referenceEvents(entry, group.windows, group.events).size, 0);
+});
+
+test('target codon uses RNA-call coordinates, independently of the vaccine mismatch', () => {
+ const { data, add } = annotatedFixture();
+ add('full|orf_0-68', 'SFSGPGMSGMALMEVNLLSGFMV', 7);
+ const rows = sequenceRows(data), [group] = prepareComparisons(data, rows);
+ const event = group.events.get(group.windows[0]);
+ assert.equal(event.size, 1);
+ assert.equal(event.get(6).kind, 'mutation');
+ assert.equal(group.vaccineDifferences.windows.get(group.windows[0]).has(6), false);
+ assert.deepEqual([...referenceEvents(group.references[0], group.windows, group.events).keys()], [6]);
+});
+
+test('grouped target locations preserve ambiguity and do not invent a reference annotation', () => {
+ const { data, add, sequence } = annotatedFixture();
+ add('a|orf_0-68', sequence, 7, true);
+ add('b|orf_0-68', sequence, 9, true);
+ const [group] = prepareComparisons(data, sequenceRows(data));
+ const events = group.events.get(group.windows[0]);
+ assert.deepEqual([...events.keys()].sort((a,b) => a-b), [6, 8]);
+ assert.ok([...events.values()].every(x => x.kind === 'ambiguous'));
+ assert.equal(referenceEvents(group.references[0], group.windows, group.events).size, 0);
+});
+
+test('frameshift tails survive cropping without claiming novel ORF boundaries', () => {
+ const { data, variant, add } = annotatedFixture();
+ variant.ref = 'AG'; variant.alt = 'A';
+ add('a|orf_0-68', 'SFSGPGMSGMALMEVNLLSGFMV', 1);
+ const [group] = prepareComparisons(data, sequenceRows(data));
+ const window = group.windows[0];
+ const cropped = { ...window, sequence: window.sequence.slice(4), offset: window.offset + 4 };
+ const events = windowEvents(cropped, variant);
+ assert.equal(events.get(0).kind, 'mutation');
+ assert.equal(events.get(4).kind, 'tail');
+ assert.match(events.get(4).title, /not annotated/);
+});
+
+test('top windows rank RNA inputs, not vaccine containment or row order, and retain ties', () => {
+ const { data, add, sequence } = annotatedFixture();
+ add('match|orf_0-68', sequence, 7, true);
+ add('mismatch1|orf_0-68', 'SFSGPGMSGMALMEVNLLSGFMV', 7);
+ add('mismatch2|orf_0-68', 'SFSGPGMSGMALMEVNLLSGFMV', 7);
+ add('match|orf_3-71', sequence, 7, true); // same input, another ORF: still one RNA
+ add('corrected|orf_0-68', sequence, 7, true, 'corrected');
+ let rows = sequenceRows(data), groups = prepareComparisons(data, rows);
+ let top = topWindows(data, groups, rows);
+ const raw = top.find(x => x.method === 'reads'), corrected = top.find(x => x.method === 'corrected');
+ assert.equal(raw.count, 2);
+ assert.equal(raw.leaders.length, 1);
+ assert.notEqual(raw.leaders[0].window.number, 1);
+ assert.equal(raw.disagreement, true);
+ assert.equal(corrected.count, 1);
+ assert.equal(corrected.disagreement, false);
+ add('match2|orf_0-68', sequence, 7, true);
+ rows = sequenceRows(data); groups = prepareComparisons(data, rows);
+ top = topWindows(data, groups, rows, { method: 'reads' });
+ assert.equal(top.length, 1);
+ assert.equal(top[0].leaders.length, 2);
+ assert.deepEqual(top[0].leaders.map(x => x.peptides[0].status).sort(), ['contained', 'disagreement']);
+});
+
+test('top-window missing coverage, unaligned output and no output stay separate from disagreement', () => {
+ const { data, add } = annotatedFixture();
+ add('short|orf_0-50', 'MSGMALMEVNLLSGKKK', 1);
+ add('unrelated|orf_0-29', 'AAAAAAAAAA', 3, false, 'corrected');
+ data.analysis.artifacts.push({ sample: 'T2', arm: 'reads' });
+ const rows = sequenceRows(data), groups = prepareComparisons(data, rows);
+ const tops = topWindows(data, groups, rows);
+ assert.equal(tops[0].leaders[0].peptides[0].status, 'incomplete');
+ assert.equal(tops[1].leaders[0].peptides[0].status, 'unaligned');
+ assert.deepEqual(tops[2].leaders, []);
+ assert.ok(tops.every(x => !x.disagreement));
+});
+
+test('RNA-linked containment cannot leak into a leader from a different method', () => {
+ const { data, add, sequence } = annotatedFixture();
+ add('raw|orf_0-68', sequence, 7, false);
+ add('corrected|orf_0-68', sequence, 7, true, 'corrected');
+ const rows = sequenceRows(data), groups = prepareComparisons(data, rows);
+ const tops = topWindows(data, groups, rows);
+ assert.equal(tops[0].leaders[0].peptides[0].status, 'unconfirmed');
+ assert.equal(tops[1].leaders[0].peptides[0].status, 'contained');
+});
+
+test('RNA evidence tiers follow sample selection and remain independent of method', () => {
+ const { data, variant } = annotatedFixture();
+ variant.rna_support = { category: 'multiple', by_sample: { T1: { category: 'single' }, T2: { category: 'multiple' } } };
+ assert.equal(sequenceRows(data, { rnaTier: 'multiple' }).length, 1);
+ assert.equal(sequenceRows(data, { rnaTier: 'multiple', sample: 'T1' }).length, 0);
+ assert.equal(sequenceRows(data, { rnaTier: 'single', sample: 'T1', method: 'corrected' }).length, 1);
+});
diff --git a/tests/test_web_results.js b/tests/test_web_results.js
index 4340f5c..ee90f22 100644
--- a/tests/test_web_results.js
+++ b/tests/test_web_results.js
@@ -195,3 +195,35 @@ test("an empty later page stops polling that jobs snapshot", async () => {
assert.equal(node.children.find((child) => child.className === "live-jobs").children.length, 100);
assert.equal(requests.length, 3);
});
+
+test('amino-acid failures are named beside the score and show the actual failed residue', () => {
+ const data = result(3, 2, { reads: { outcome: 'proteoform' } });
+ data.variants = [
+ { gene: 'ABI3BP', protein_change: 'p.Arg472Met', rna_support: { category: 'multiple' }, recovery: {
+ outcome: 'proteoform', residue_confirmed: false, expected: { alt_aa: 'M' }, samples: {
+ T2: { arms: { reads: { proteoforms: [{ context: 'AAWBB', context_start: 10,
+ mutant_residue_indices: [11], mutant_residues: 'W' }] } } },
+ },
+ } },
+ { gene: 'CONTROL', rna_support: { category: 'single' }, recovery: { outcome: 'peptide', residue_confirmed: true } },
+ { gene: 'NO_PROTEIN', rna_support: { category: 'zero' }, recovery: { outcome: 'rna_only', residue_confirmed: false } },
+ ];
+ const nodes = render(data, ['renderVerdict', 'renderEvidenceBreakdown']);
+ assert.match(nodes['#verdict'].textContent, /1 amino-acid failures: ABI3BP/);
+ assert.doesNotMatch(nodes['#residue-failures'].textContent, /CONTROL|NO_PROTEIN/);
+ assert.match(nodes['#residue-failures'].textContent, /Expected M; observed W/);
+ const sequence = nodes['#residue-failures'].children[1].children.find(x => x.tag === 'code');
+ assert.equal(sequence.children[2].className, 'sequence-vaccine-difference');
+ assert.equal(sequence.children[2].textContent, 'W');
+ assert.match(nodes['#rna-breakdown'].textContent, /2\+ mutant RNA reads11\/10\/1/);
+ assert.match(nodes['#rna-breakdown'].textContent, /Single-read evidence11\/11\/1/);
+});
+
+test('RNA support groups do not score failed or missing outputs as failed recovery', () => {
+ const data = result(0, 0);
+ data.variants[0].rna_support = { category: 'multiple' };
+ data.variants[0].recovery.outcome = 'no_reads';
+ const nodes = render(data, ['renderEvidenceBreakdown']);
+ assert.match(nodes['#rna-breakdown'].textContent, /2\+ mutant RNA reads1——/);
+ assert.doesNotMatch(nodes['#rna-breakdown'].textContent, /0\/1/);
+});
diff --git a/web/app.js b/web/app.js
index d7710ae..3e71093 100644
--- a/web/app.js
+++ b/web/app.js
@@ -244,6 +244,68 @@ function renderVerdict() {
`${recovered} of ${testable} covered vaccine mutations came back as a translated ` +
`mutant protein sequence${residues}${version}`),
);
+ const failures = DATA.variants.filter(residueFailure);
+ if (failures.length) {
+ const link = el("a", "residue-failure-link", `${failures.length} amino-acid failures: ${failures.map(v => v.gene).join(", ")}`);
+ link.href = "#residue-failures";
+ node.append(link);
+ }
+}
+
+function residueFailure(variant) {
+ return variant.recovery?.residue_confirmed === false && RECOVERED.has(variant.recovery?.outcome);
+}
+
+const rnaTierLabels = { multiple: "2+ mutant RNA reads", single: "Single-read evidence",
+ zero: "0 mutant reads observed", unknown: "RNA counts unavailable" };
+
+function renderEvidenceBreakdown() {
+ const container = $("#rna-breakdown");
+ container.innerHTML = "";
+ if (!DATA.has_exacto_run) return;
+ const table = el("table", "rna-breakdown-table"), header = el("tr");
+ ["Sid RNA support", "Targets", "Protein recovered", "Right amino acid"].forEach(text => header.append(el("th", null, text)));
+ table.append(header);
+ for (const category of ["multiple", "single", "zero", "unknown"]) {
+ const variants = DATA.variants.filter(v => (v.rna_support?.category || "unknown") === category);
+ if (!variants.length) continue;
+ const recovered = variants.filter(v => RECOVERED.has(v.recovery?.outcome));
+ const evaluated = variants.filter(v => RECOVERED.has(v.recovery?.outcome) ||
+ Object.values(v.recovery?.samples || {}).some(sample => Object.keys(sample.arms || {}).length));
+ const checkable = recovered.filter(v => typeof v.recovery.residue_confirmed === "boolean");
+ const row = el("tr");
+ row.append(el("th", null, rnaTierLabels[category]), el("td", null, variants.length),
+ el("td", null, evaluated.length ? `${recovered.length}/${evaluated.length}` : "—"),
+ el("td", checkable.some(v => !v.recovery.residue_confirmed) ? "residue-failure" : null,
+ checkable.length ? `${checkable.filter(v => v.recovery.residue_confirmed).length}/${checkable.length}` : "—"));
+ table.append(row);
+ }
+ container.append(table);
+ const failures = $("#residue-failures");
+ failures.innerHTML = "";
+ const failed = DATA.variants.filter(residueFailure);
+ failures.hidden = !failed.length;
+ if (!failed.length) return;
+ failures.append(el("h3", null, "Expected amino acid not recovered"));
+ for (const variant of failed) {
+ const entry = el("div", "residue-failure-example");
+ entry.append(el("strong", null, `${variant.gene} ${variant.protein_change}`));
+ const examples = Object.entries(variant.recovery.samples || {}).flatMap(([sample, values]) =>
+ Object.entries(values.arms || {}).flatMap(([method, arm]) =>
+ (arm.proteoforms || []).map(form => ({ sample, method, form }))));
+ const seen = new Set();
+ for (const { sample, method, form } of examples) {
+ const key = JSON.stringify([form.context, form.mutant_residue_indices]);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ entry.append(el("span", "residue-failure", `Expected ${variant.recovery.expected?.alt_aa || "?"}; observed ${form.mutant_residues || "?"}`));
+ const sequence = el("code", "failure-sequence");
+ [...(form.context || "")].forEach((residue, i) => sequence.append(
+ el("span", (form.mutant_residue_indices || []).includes(i + (form.context_start || 1) - 1) ? "sequence-vaccine-difference" : null, residue)));
+ entry.append(sequence, el("span", "locus", `${sample} · ${method}`));
+ }
+ failures.append(entry);
+ }
}
function renderVaccineSequenceSummary() {
@@ -510,11 +572,15 @@ function visibleVariants() {
const query = $("#filter").value.trim().toLowerCase();
const onlyRecovered = $("#only-recovered").checked;
const onlyElispot = $("#only-elispot").checked;
+ const onlyFailures = $("#only-residue-failures").checked;
+ const tier = $("#rna-tier").value;
return DATA.variants
.filter((variant) => {
if (onlyRecovered && !RECOVERED.has(outcomeOf(variant, null))) return false;
if (onlyElispot && variant.elispot.status !== "positive") return false;
+ if (onlyFailures && !residueFailure(variant)) return false;
+ if (tier && (variant.rna_support?.category || "unknown") !== tier) return false;
if (!query) return true;
const haystack = [
variant.gene, variant.protein_change, variant.vaccine_label,
@@ -759,7 +825,9 @@ function renderTable() {
for (const variant of rows) {
const row = el("tr", "row");
- row.append(el("td", "gene sticky-col", variant.gene));
+ const gene = el("td", "gene sticky-col", variant.gene);
+ if (variant.rna_support?.category === "single") gene.append(el("span", "badge warn rna-tier-badge", "1 RNA/library"));
+ row.append(gene);
row.append(el("td", "change sticky-col", variant.protein_change || variant.vaccine_label || "—"));
row.append(el("td", "locus", `${variant.chrom}:${variant.pos?.toLocaleString() ?? "unresolved"}`));
@@ -786,8 +854,8 @@ function renderTable() {
const outcome = el("td", "sticky-right");
outcome.append(sampleCells(variant));
- if (variant.recovery?.residue_confirmed === false) {
- const warn = el("span", "badge warn", "wrong residue");
+ if (residueFailure(variant)) {
+ const warn = el("span", "badge bad", "expected aa absent");
warn.style.marginLeft = ".35rem";
warn.title = "A mutant proteoform came back, but not carrying the annotated change";
outcome.append(warn);
@@ -1697,6 +1765,8 @@ function wireControls() {
$("#filter").addEventListener("input", renderTable);
$("#only-recovered").addEventListener("change", renderTable);
$("#only-elispot").addEventListener("change", renderTable);
+ $("#only-residue-failures").addEventListener("change", renderTable);
+ $("#rna-tier").addEventListener("change", renderTable);
document.querySelectorAll("#variant-table thead th[data-sort]").forEach((header) => {
header.addEventListener("click", () => {
@@ -1730,6 +1800,7 @@ async function main() {
renderVerdict();
renderTiles();
renderVaccineSequenceSummary();
+ renderEvidenceBreakdown();
renderLegend();
renderHistory();
renderHead();
diff --git a/web/index.html b/web/index.html
index e6f9d7a..07a1a57 100644
--- a/web/index.html
+++ b/web/index.html
@@ -5,7 +5,7 @@
Does Exacto Work Yet?
-
+
@@ -52,6 +52,14 @@
Recovery by sample
+
+
Recovery by RNA support
+
+
2+ means at least two mutant reads in one benchmark-matched Sid RNA library at any
+ timepoint. Single-read evidence means a maximum of one in the reported libraries; unreported libraries
+ remain unknown. Counts are never added across libraries, timepoints or Exacto methods.
+ Protein fractions use evaluated targets; amino-acid fractions use recovered, checkable proteins.
+ Stronger locus support does not guarantee the coding span needed for a complete peptide.