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.

@@ -360,6 +370,6 @@

What this test does not do

- + diff --git a/web/peptides.html b/web/peptides.html index 5ed15a3..8331d6d 100644 --- a/web/peptides.html +++ b/web/peptides.html @@ -5,7 +5,7 @@ Vaccine sequences — Does Exacto Work Yet? - +
@@ -32,10 +32,23 @@

Per-sequence report

P rows are recorded vaccine peptides; W rows group proteins identical within the comparison window. The default window is 45 aa and expands for longer references. All sequences remain visible; wide alignments scroll horizontally. Long regions continue in blocks.

-

Green marks full vaccine containment. Amber marks differences from the first W row in each +

Green marks full vaccine containment. Rose marks differences between a vaccine peptide and + a reconstruction at overlapping positions. Click a W label to compare the vaccine rows against it + (initially W1); W-row marks always compare against the vaccine peptides. Hover for residue numbers. + Amber marks other differences from the first W row in each aligned group. S17G means S in the reference window and G at position 17 in this window. Positions refer to the window, not the full protein. Missing ends are coverage differences. Shared sequence anchors position rows without inserting gaps; unaligned candidates stay separate.

+

Blue underlines mark the translated target codon from the RNA call. For a deletion this is + the codon at/after its junction; for a frameshift, the dotted blue tail means downstream of + the event, not verified novelty at every residue. Dotted target marks indicate differing + locations among grouped outputs. Vaccine-row marks require an unambiguous exact local anchor. + The current panel has no annotated fusion breakpoints or novel-ORF boundaries; these are not inferred.

+

★ marks the window(s) with the most distinct RNA inputs within each sample and method, + including all ties. Exacto emits unranked candidates: this is a report support rule, not its + confidence score. “Top ≠ vaccine” means at least one leader differs from at least one displayed + vaccine peptide at a covered position, even if another peptide is contained. Missing ends and + unaligned outputs are reported separately in the details. W1 is a display reference, not a rank.

Match: ✓ contained; ≠ protein produced but vaccine sequence absent or incomplete; ∅ no reconstructed protein; — not evaluated. W rows name the peptides they contain.

RNA counts: r raw reads; c corrected; a assembly; a1 assembly-ms1; ap permissive assembly; @@ -45,6 +58,10 @@

Per-sequence report

Sid counts are independent mutant / total reads at the locus: green means mutant reads; amber means covered with zero mutant reads; 0 / 0 means no coverage; — means not reported. Locus support does not establish full peptide recovery. Libraries stay separate.

+

RNA support groups use the maximum Sid mutant-read count in a benchmark-matched library: + 2+ in any library, single-read evidence, observed zero, or unknown. Sample filtering uses that + sample's library; methods do not change independent Sid counts. Missing libraries remain unknown. + Two reads are a support threshold, not a guarantee of complete vaccine-sequence recovery.

ACD vaccine contained - G changed residue + G differs from vaccine + G differs from W1 + M target codon + ★ top RNA support (ties kept) + Click W to compare vaccine rows 7r raw reads · 2c corrected · 1s SPAdes ✓ match · ≠ disagreement · ∅ no protein · — unavailable
@@ -71,11 +94,19 @@

References and evidence

Longer vaccine peptides and mRNA minimal epitopes retain separate entries. Matching uses the full exported protein sequences and requires the target RNA allele at the peptide’s translated position, or its frameshifted tail.

+
+ CD109’s terminal KKK +

The source vaccine table + records SFSGPGMSGMALMEVNLLSGKKK but does not identify an added tag. A solubility modification is + plausible, not confirmed. Some reconstructed proteins contain the full 20-aa prefix + SFSGPGMSGMALMEVNLLSG and continue FMV instead of KKK; others also lack the N-terminal part. + The score retains the complete recorded peptide until the intended biological sequence is documented.

+

Reference peptides (FASTA) · Reference annotations (JSON) · Analysis and reconstructed sequences (JSON)

- + diff --git a/web/peptides.js b/web/peptides.js index 8d5556b..9cfaff1 100644 --- a/web/peptides.js +++ b/web/peptides.js @@ -13,6 +13,8 @@ function sequenceRows(report, filters = {}) { (!filters.vaccine || peptide.in_vaccines.includes(filters.vaccine)) && (!filters.sourceRna || sourceRnaState((variant.source_rna_support || []).filter((entry) => !filters.sample || entry.benchmark_sample === filters.sample)) === filters.sourceRna) && + (!filters.rnaTier || ((filters.sample ? variant.rna_support?.by_sample?.[filters.sample]?.category : + variant.rna_support?.category) || "unknown") === filters.rnaTier) && (!query || [variant.gene, variant.protein_change, variant.variant_id, peptide.sequence, ...peptide.in_vaccines].join(" ").toLowerCase().includes(query))); } @@ -235,6 +237,124 @@ function windowSlice(window, start, end) { return slice; } +// Display comparisons are ungapped and only call differences in covered columns. +// Containment still comes from the archived, allele-linked full-protein check. +function vaccineComparison(window, entry) { + const { peptide, variant } = entry.row; + const label = `P${variant.published_vaccine_peptides.indexOf(peptide) + 1}`; + const substitutions = [], positions = new Map(); + let missing = 0; + for (let i = 0; i < peptide.sequence.length; i++) { + const column = entry.offset + i, reference = peptide.sequence[i]; + const residue = window.sequence[column - window.offset]; + if (residue === undefined) { missing++; continue; } + if (reference !== residue) { + const change = { column, position: i + 1, reference, residue }; + substitutions.push(change); + positions.set(column, `${label} ${reference}${i + 1} → W${window.number} ${residue} (vaccine comparison)`); + } + } + return { label, substitutions, positions, missing }; +} + +function vaccineDifferences(group) { + const windows = new Map(), references = new Map(group.references.map((entry) => [entry, new Map()])); + for (const window of group.windows) { + const positions = new Map(); + for (const entry of group.references) { + for (const [column, description] of vaccineComparison(window, entry).positions) { + positions.set(column, [positions.get(column), description].filter(Boolean).join("; ")); + if (window === group.windows[0]) references.get(entry).set(column, description); + } + } + windows.set(window, positions); + } + return { windows, references }; +} + +function windowEvents(window, variant) { + const candidates = new Map((variant.reconstructed_candidates || []).map((candidate) => + [JSON.stringify([candidate.reconstruction_id, candidate.rna_call_id]), candidate])); + const locations = new Set(); + for (const { protein } of window.members) for (const key of protein.observations.keys()) { + const start = candidates.get(key)?.variant_amino_acid_start; + if (Number.isInteger(start) && start > 0) locations.add(protein.offset + start - 1); + } + const frameshift = Boolean(variant.ref && variant.alt && (variant.alt.length - variant.ref.length) % 3); + const deletion = Boolean(variant.ref && variant.alt && variant.ref.length > variant.alt.length); + const label = frameshift ? "First translated codon at the target frameshift" : deletion + ? "First translated codon at/after the target deletion junction" : "Target mutation codon"; + const positions = new Map(); + for (const column of locations) positions.set(column, { + kind: locations.size > 1 ? "ambiguous" : "mutation", + title: `${label} · ${variant.protein_change || variant.variant_id}. RNA-call position in this translation` + + (locations.size > 1 ? "; grouped outputs place the event at different positions" : ""), + }); + // A tail is a positional aid, not a claim that every residue is novel or that + // the frame remains shifted after other events. Never invent novel-ORF bounds. + if (frameshift && locations.size === 1) { + const start = [...locations][0]; + for (let column = Math.max(start + 1, window.offset); column < window.offset + window.sequence.length; column++) + positions.set(column, { kind: "tail", title: "Downstream of the target frameshift; novelty and frame-restoration boundaries are not annotated" }); + } + return positions; +} + +function referenceEvents(entry, windows, events) { + const positions = new Set(), sequence = entry.row.peptide.sequence; + for (const window of windows) for (const [column, event] of events.get(window)) { + if (event.kind !== "mutation") continue; + const index = column - entry.offset; + if (index < 0 || index >= sequence.length) continue; + const from = Math.max(entry.offset, window.offset, column - 3); + const end = Math.min(entry.offset + sequence.length, window.offset + window.sequence.length, column + 4); + if (end - from >= 6 && sequence.slice(from - entry.offset, end - entry.offset) === + window.sequence.slice(from - window.offset, end - window.offset)) positions.add(column); + } + // Require agreement across exact local anchors; never guess from peptide + // midpoint, canonical protein numbering, or minimal_epitope_offset. + return positions.size === 1 ? new Map([[[...positions][0], { kind: "mutation", + title: "Target codon projected from RNA-call coordinates through an exact local sequence anchor" }]]) : new Map(); +} + +function prepareComparisons(report, rows, filters = {}) { + const groups = sequenceComparison(report, rows, filters); + let number = 1; + for (const group of groups) { + group.windows = reconstructionWindows(group, group.start, group.end, number); + number += group.windows.length; + group.comparisons = group.unaligned ? null : windowDifferences(group.windows, group.start, group.end); + group.vaccineDifferences = vaccineDifferences(group); + group.events = new Map(group.windows.map((window) => [window, windowEvents(window, rows[0].variant)])); + } + return groups; +} + +function topWindows(report, groups, rows, filters = {}) { + const windows = groups.flatMap((group) => group.windows.map((window) => ({ window, group }))); + return report.analysis.artifacts.filter((run) => selectedRun(run, filters)).map((run) => { + const counts = windows.map(({ window }) => rnaSupport(report, [...window.observations.values()], + { sample: run.sample, method: run.arm })[0]?.methods[0]?.count || 0); + const count = Math.max(0, ...counts); + const leaders = windows.filter((_, i) => count > 0 && counts[i] === count).map(({ window, group }) => { + const peptides = rows.map(({ peptide }) => { + const ids = new Set([...window.observations.values()].filter((hit) => selectedRun(hit, { sample: run.sample, method: run.arm })) + .map((hit) => JSON.stringify([hit.reconstruction_id, hit.rna_call_id]))); + const contained = peptide.matches.some((hit) => selectedRun(hit, { sample: run.sample, method: run.arm }) && + ids.has(JSON.stringify([hit.reconstruction_id, hit.rna_call_id])) && window.peptideIds.has(peptide.peptide_id)); + const entry = group.references.find((entry) => entry.row.peptide === peptide); + const comparison = entry ? vaccineComparison(window, entry) : null; + const status = contained ? "contained" : !comparison ? "unaligned" : comparison.substitutions.length + ? "disagreement" : comparison.missing ? "incomplete" : "unconfirmed"; + return { peptide, status, comparison }; + }); + return { window, peptides }; + }); + return { sample: run.sample, method: run.arm, count, leaders, + disagreement: leaders.some((leader) => leader.peptides.some((peptide) => peptide.status === "disagreement")) }; + }); +} + const statusText = { contained: "Contained", sequence_disagreement: "Sequence disagreement", no_sequence: "No reconstructed sequence", not_evaluated: "Not evaluated" }; @@ -259,7 +379,7 @@ const methodCodes = { reads: "r", corrected: "c", assembly: "a", "assembly-ms1": "assembly-permissive": "ap", "assembly-unspliced": "au", isonform: "i", spades: "s" }; const statusSymbols = { contained: "✓", sequence_disagreement: "≠", no_sequence: "∅", not_evaluated: "—" }; -function supportCells(report, matches, filters, variant = null) { +function supportCells(report, matches, filters, variant = null, leaders = []) { const cells = []; for (const support of rnaSupport(report, matches, filters)) { const cell = node("td", "alignment-reads"); @@ -278,6 +398,12 @@ function supportCells(report, matches, filters, variant = null) { for (const { method, count } of displayed) { const value = node("span", count ? "support-count positive" : "support-count"); value.append(node("strong", "", String(count)), node("span", "support-method", methodCodes[method] || method)); + const leader = leaders.find((run) => run.sample === support.sample && run.method === method); + if (leader) { + value.classList.add("support-leader"); + value.append(node("span", "support-star", "★")); + value.title = `${support.sample} ${method}: ${leader.leaders.length > 1 ? "Tied for most" : "Most"} distinct RNA inputs (${count}) among displayed windows; not an Exacto confidence rank`; + } cell.append(value); } } @@ -353,19 +479,22 @@ function sourceRnaEvidence(report, variant, filters) { return section; } -function sequenceStrip(slice, window = null, comparison = null) { +function sequenceStrip(slice, window = null, comparison = null, vaccine = new Map(), events = new Map()) { const strip = node("code", "sequence-strip"); strip.append(node("span", "sequence-trim", slice.clippedLeft ? "…" : " "), document.createTextNode(slice.left)); let column = (window?.offset || 0) + slice.from; for (const piece of slice.pieces) { const segment = node(piece.matched ? "mark" : "span", ""); for (const residue of piece.text) { - const difference = comparison?.positions.get(column++); - if (difference) { - const aminoAcid = node("span", "sequence-difference", residue); - aminoAcid.title = difference; + const difference = comparison?.positions.get(column), mismatch = vaccine.get(column), event = events.get(column); + if (difference || mismatch || event) { + const classes = [mismatch ? "sequence-vaccine-difference" : difference ? "sequence-difference" : "", + event ? `sequence-event sequence-event-${event.kind}` : ""].filter(Boolean).join(" "); + const aminoAcid = node("span", classes, residue); + aminoAcid.title = [mismatch, difference, event?.title].filter(Boolean).join("; "); segment.append(aminoAcid); } else segment.append(document.createTextNode(residue)); + column++; } strip.append(segment); } @@ -390,7 +519,7 @@ function sequenceRuler(start, end, origin) { return node("code", "sequence-ruler", ` ${columns.join("")} \n ${ticks.join("")} `); } -function referenceRow(report, entry, slice, part, filters) { +function referenceRow(report, entry, slice, part, filters, group) { const { row } = entry; const item = node("tr", "alignment-reference"); item.id = `${row.peptide.peptide_id}-part-${part}`; @@ -398,7 +527,11 @@ function referenceRow(report, entry, slice, part, filters) { label.scope = "row"; label.title = `${row.peptide.in_vaccines.join(", ")} · ${row.peptide.sequence.length} aa · ${row.peptide.is_mrna_minimal_epitope ? "Minimal epitope" : "Vaccine peptide"}`; const sequence = node("td", "alignment-sequence"); - sequence.append(sequenceStrip(slice)); + const update = () => sequence.replaceChildren(sequenceStrip(slice, entry, null, + group.referenceWindow ? vaccineComparison(group.referenceWindow, entry).positions : new Map(), + referenceEvents(entry, group.windows, group.events))); + group.referenceDisplays.push(update); + update(); const match = node("td", `alignment-match ${row.status}`, statusSymbols[row.status]); match.title = statusText[row.status]; match.setAttribute("aria-label", statusText[row.status]); @@ -420,13 +553,20 @@ function differenceDescription(comparison) { return changes.join(", "); } -function reconstructionRow(report, window, slice, filters, rows, comparison) { +function reconstructionRow(report, window, slice, filters, rows, comparison, group, top) { const item = node("tr", "alignment-reconstruction"); - const label = node("th", "alignment-label", `W${window.number}`); + const label = node("th", "alignment-label"); label.scope = "row"; label.title = windowMetadata(window); + const compare = node("button", "window-compare", `W${window.number}`); + compare.type = "button"; + compare.title = `Compare vaccine rows with W${window.number}`; + compare.setAttribute("aria-pressed", String(group.referenceWindow === window)); + compare.addEventListener("click", () => group.selectWindow(window)); + group.compareButtons.push({ window, button: compare }); + label.append(compare); const sequence = node("td", "alignment-sequence"); - sequence.append(sequenceStrip(slice, window, comparison)); + sequence.append(sequenceStrip(slice, window, comparison, group.vaccineDifferences.windows.get(window), group.events.get(window))); const contained = rows.filter(({ peptide }) => window.peptideIds.has(peptide.peptide_id)).map(({ variant, peptide }) => `P${variant.published_vaccine_peptides.indexOf(peptide) + 1}`); const match = node("td", `alignment-match ${contained.length ? "contained" : "sequence_disagreement"}`, contained.join(" ") || "≠"); @@ -444,23 +584,27 @@ function reconstructionRow(report, window, slice, filters, rows, comparison) { if (missing || additional) changes.append(node("span", "coverage-badge", "coverage")); } } else changes.append(node("span", "support-empty", "—")); - item.append(label, sequence, match, changes, ...supportCells(report, [...window.observations.values()], filters)); + item.append(label, sequence, match, changes, ...supportCells(report, [...window.observations.values()], filters, null, + top.filter((run) => run.leaders.some((leader) => leader.window === window)))); return item; } -function targetComparison(report, rows, filters, columns) { +function targetComparison(report, rows, filters, columns, groups, top) { const fragment = document.createDocumentFragment(); - const groups = sequenceComparison(report, rows, filters); const samples = rnaSupport(report, [], filters); - let nextWindow = 1; - for (const group of groups) { - group.windows = reconstructionWindows(group, group.start, group.end, nextWindow); - group.comparisons = group.unaligned ? null : windowDifferences(group.windows, group.start, group.end); - nextWindow += group.windows.length; - } const reconstructionIds = new Set(groups.flatMap((group) => group.reconstructions.flatMap((p) => [...p.observations.values()].map((hit) => hit.reconstruction_id)))); // Reference and reconstruction support share the same sample columns. groups.forEach((group, groupIndex) => { + group.referenceWindow = group.windows[0]; + group.referenceDisplays = []; + group.referenceCaptions = []; + group.compareButtons = []; + group.selectWindow = (window) => { + group.referenceWindow = window; + group.referenceDisplays.forEach(update => update()); + group.referenceCaptions.forEach(caption => { caption.textContent = `P vs W${window.number}`; }); + group.compareButtons.forEach(item => item.button.setAttribute("aria-pressed", String(item.window === window))); + }; if (group.unaligned) fragment.append(node("p", "sequence-alignment-note", "Unaligned candidates · mutation-centered windows; no shared vaccine anchor.")); else if (groups.filter((g) => !g.unaligned).length > 1) fragment.append(node("p", "sequence-alignment-note", `Alignment group ${groupIndex + 1}`)); const nParts = Math.ceil((group.end - group.start) / columns); @@ -472,6 +616,11 @@ function targetComparison(report, rows, filters, columns) { panel.setAttribute("aria-label", `${rows[0].variant.gene} sequence alignment${nParts > 1 ? `, region ${part}` : ""}`); const table = node("table", "sequence-alignment"); const caption = node("caption", "sequence-alignment-note", `${group.end - group.start} aa${nParts > 1 ? ` · region ${part}/${nParts}` : ""}`); + if (group.references.length && group.windows.length) { + const comparing = node("span", "", `P vs W${group.referenceWindow.number}`); + group.referenceCaptions.push(comparing); + caption.append(document.createTextNode(" · "), comparing); + } const head = node("thead"), header = node("tr"); header.append(node("th", "", "Seq"), node("th", "", "Sequence"), node("th", "", "Match"), node("th", "", group.unaligned || !group.windows.length ? "Changes" : `Δ vs W${group.windows[0].number}`)); @@ -487,11 +636,11 @@ function targetComparison(report, rows, filters, columns) { const body = node("tbody"); for (const entry of group.references) { const slice = alignmentSlice(entry.row.peptide.sequence, entry.offset, start, end); - if (slice) body.append(referenceRow(report, entry, slice, part, filters)); + if (slice) body.append(referenceRow(report, entry, slice, part, filters, group)); } for (const window of group.windows) { const slice = windowSlice(window, start, end); - if (slice) body.append(reconstructionRow(report, window, slice, filters, rows, group.comparisons?.get(window))); + if (slice) body.append(reconstructionRow(report, window, slice, filters, rows, group.comparisons?.get(window), group, top)); } table.append(caption, head, body); panel.append(table); @@ -511,6 +660,24 @@ function targetComparison(report, rows, filters, columns) { (comparison && comparison.reference !== window.number ? `. vs W${comparison.reference}: ${differenceDescription(comparison)}` : ""))); } details.append(metadata); + const leaderTable = node("table", "sequence-top-details"); + const leaderHead = node("tr"); + ["Top RNA windows", "Inputs", "Vaccine comparison"].forEach((label) => leaderHead.append(node("th", "", label))); + leaderTable.append(leaderHead); + for (const run of top.filter((run) => run.count)) { + for (const leader of run.leaders) { + const row = node("tr"); + const descriptions = leader.peptides.map(({ peptide, status, comparison }) => { + const label = `P${rows[0].variant.published_vaccine_peptides.indexOf(peptide) + 1}`; + const changes = comparison?.substitutions.map(({ position, reference, residue }) => `${reference}${position}${residue}`).join(", "); + return `${label}: ${status}${changes ? ` (${changes})` : ""}${comparison?.missing ? `; ${comparison.missing} aa not covered` : ""}`; + }); + row.append(node("td", "", `${run.sample} ${run.method} · W${leader.window.number}${run.leaders.length > 1 ? " (tie)" : ""}`), + node("td", "", String(run.count)), node("td", "", descriptions.join(" · "))); + leaderTable.append(row); + } + } + if (top.some((run) => run.count)) details.append(leaderTable); fragment.append(details); if (!reconstructionIds.size) { const explanations = { @@ -536,6 +703,7 @@ function renderSequenceRows(report) { method: queryNode("#sequence-method").value, vaccine: queryNode("#sequence-vaccine").value, sourceRna: queryNode("#sequence-source-rna").value, + rnaTier: queryNode("#sequence-rna-tier").value, }; const rows = sequenceRows(report, filters); const groups = new Map(); @@ -543,21 +711,36 @@ function renderSequenceRows(report) { if (!groups.has(row.variant.variant_id)) groups.set(row.variant.variant_id, []); groups.get(row.variant.variant_id).push(row); }); - queryNode("#sequence-count").textContent = `${rows.length} peptides across ${groups.size} target${groups.size === 1 ? "" : "s"} shown`; const container = queryNode("#sequence-targets"); container.replaceChildren(); + let shown = 0, peptidesShown = 0, topDisagreements = 0; const columns = Math.max(45, Math.min(72, Math.floor((container.clientWidth - 650) / 9.6) - 2)); for (const entries of groups.values()) { const variant = entries[0].variant; + const comparisons = prepareComparisons(report, entries, filters); + const top = topWindows(report, comparisons, entries, filters); + const disagreements = top.filter((run) => run.disagreement); + if (queryNode("#sequence-top").value === "disagreement" && !disagreements.length) continue; + shown++; + peptidesShown += entries.length; + topDisagreements += disagreements.length > 0; const target = node("article", "sequence-target"); const title = node("h3", "sequence-target-title"); title.append(node("span", "", variant.gene), node("span", "mono", variant.protein_change || "Protein change not annotated")); + const tier = filters.sample ? variant.rna_support?.by_sample?.[filters.sample] : variant.rna_support; + if (tier?.category === "single") title.append(node("span", "badge warn", "1 RNA/library")); + if (disagreements.length) { + const badge = node("span", "top-disagreement-badge", "Top ≠ vaccine"); + badge.title = `${disagreements.map((run) => `${run.sample} ${run.method}${run.leaders.length > 1 ? " (tied leaders)" : ""}`).join("; ")}. At least one most-supported window differs at a covered vaccine position. See starred counts and peptide & protein details.`; + title.append(badge); + } target.append(title); - target.append(targetComparison(report, entries, filters, columns)); + target.append(targetComparison(report, entries, filters, columns, comparisons, top)); target.append(sourceRnaEvidence(report, variant, filters)); container.append(target); } - if (!rows.length) container.append(node("p", "empty", "No sequences match these filters. Try another gene, sequence, or recovery status.")); + queryNode("#sequence-count").textContent = `${peptidesShown} peptides across ${shown} targets shown · ${topDisagreements} with a top-supported window differing from a vaccine peptide`; + if (!shown) container.append(node("p", "empty", "No sequences match these filters. Try another gene, sequence, or recovery status.")); } function populateSequenceReport(report) { @@ -568,6 +751,13 @@ function populateSequenceReport(report) { const { summary, analysis } = report; queryNode("#sequence-summary").textContent = `${summary.n_variants_any_peptide_matched}/${summary.n_variants} targets have a vaccine sequence contained in a reconstruction. ` + `${summary.n_peptide_entries_matched}/${summary.n_peptide_entries} recorded peptide entries recovered across all available samples and methods.`; + const stronger = report.variants.filter(variant => variant.rna_support?.category === "multiple"); + const weaker = report.variants.filter(variant => variant.rna_support?.category === "single"); + const recovered = variants => variants.filter(variant => variant.published_vaccine_peptides.some(peptide => peptide.matches.length)).length; + if (stronger.length) queryNode("#sequence-summary").append(document.createTextNode( + ` With 2+ mutant RNA reads in a tested library: ${recovered(stronger)}/${stronger.length} targets recovered.`)); + if (weaker.length) queryNode("#sequence-summary").append(document.createTextNode( + ` Single-read evidence: ${recovered(weaker)}/${weaker.length}.`)); const link = node("a", "", `Exacto ${analysis.exacto_version} · benchmark run`); link.href = analysis.run_url; queryNode("#sequence-provenance").append(link, node("span", "", ` · ${analysis.n_completed_methods} completed methods.`)); @@ -593,7 +783,7 @@ function populateSequenceReport(report) { } } queryNode("#sequence-controls").hidden = false; - for (const selector of ["#sequence-search", "#sequence-status", "#sequence-source-rna", ...Object.keys(options)]) { + for (const selector of ["#sequence-search", "#sequence-status", "#sequence-source-rna", "#sequence-top", "#sequence-rna-tier", ...Object.keys(options)]) { queryNode(selector).addEventListener("input", () => renderSequenceRows(report)); } let resizeFrame; @@ -610,7 +800,7 @@ async function main() { populateSequenceReport(await response.json()); } -if (typeof module !== "undefined") module.exports = { sequenceRows, highlightedPieces, sharedOffset, referenceGroups, sequenceComparison, alignmentSlice, reconstructionWindows, windowDifferences, windowSlice, rnaSupport, sequenceStatus, sourceRnaState }; +if (typeof module !== "undefined") module.exports = { sequenceRows, highlightedPieces, sharedOffset, referenceGroups, sequenceComparison, alignmentSlice, reconstructionWindows, windowDifferences, windowSlice, rnaSupport, sequenceStatus, sourceRnaState, vaccineComparison, vaccineDifferences, windowEvents, referenceEvents, prepareComparisons, topWindows }; if (typeof document !== "undefined") main().catch((error) => { queryNode("#sequence-summary").textContent = `The sequence report could not be loaded (${error.message}). Reload the page to try again.`; }); diff --git a/web/style.css b/web/style.css index 6603a06..e7f7644 100644 --- a/web/style.css +++ b/web/style.css @@ -132,6 +132,23 @@ a:hover { text-decoration-thickness: 2px; } .sequence-alignment .alignment-ruler td { padding-block: .05rem .3rem; border: 0; } .sequence-strip mark, .sequence-legend mark { color: var(--ok); background: var(--ok-soft); font-weight: 650; padding: .15rem 0; } .sequence-difference { color: var(--bg-raised); background: var(--warn); font-weight: 800; padding-block: .15rem; outline: 1px solid var(--warn); border-radius: 2px; } +.sequence-vaccine-difference { color: #fff; background: #a23559; font-weight: 800; padding-block: .15rem; outline: 1px solid #a23559; border-radius: 2px; } +.sequence-event { text-decoration: underline solid #2381cd 4px; text-underline-offset: .3em; } +.sequence-event-tail, .sequence-event-ambiguous { text-decoration-style: dotted; text-decoration-thickness: 3px; } +.window-compare { font: inherit; color: inherit; background: transparent; border: 0; border-bottom: 1px dotted currentColor; cursor: pointer; padding: .1rem 0; } +.window-compare[aria-pressed="true"] { border-bottom: 2px solid currentColor; } +.support-star { font-size: .7rem; align-self: flex-start; color: var(--ink); } +.support-leader strong { font-weight: 800; } +.top-disagreement-badge { font-family: var(--sans); font-size: .85rem; font-weight: 600; color: #a23559; } +.sequence-top-details { font-size: .9rem; width: 100%; } +.sequence-top-details th, .sequence-top-details td { padding: .4rem .5rem; text-align: left; vertical-align: top; } +.residue-failure-link { display: block; color: var(--bad); font-size: 1rem; margin-top: .6rem; } +.residue-failure { color: var(--bad); font-weight: 650; } +.residue-failure-example { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1.2rem; border-left: 3px solid var(--bad); padding: .6rem 1rem; margin-bottom: .5rem; } +.failure-sequence { font-family: var(--mono); font-size: 16px; overflow-wrap: anywhere; } +.rna-breakdown-table { width: auto; font-variant-numeric: tabular-nums; } +.rna-breakdown-table th, .rna-breakdown-table td { text-align: left; padding: .45rem 1rem .45rem 0; } +.rna-tier-badge { display: block; font-weight: 500; width: fit-content; } .sequence-trim { color: var(--ink-faint); } .sequence-alignment .alignment-match { min-width: 5.5rem; font-size: .9rem; } .alignment-match.contained { color: var(--ok); font-weight: 600; } @@ -159,6 +176,7 @@ a:hover { text-decoration-thickness: 2px; } .sequence-legend { display: flex; flex-wrap: wrap; align-items: center; gap: .35rem 1.3rem; font-size: .9rem; color: var(--ink-soft); margin: .65rem 0; } .sequence-legend mark { font-family: var(--mono); } .sequence-legend .sequence-difference { padding-inline: .2rem; } +.sequence-legend .sequence-vaccine-difference { padding-inline: .2rem; } .sequence-report :focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; } @media (max-width: 600px) { .sequence-controls > label { flex: 1 1 45%; min-width: 0; } From d5d4169a3063b2586a2980f722527ea0ba38d516 Mon Sep 17 00:00:00 2001 From: Alex Rubinsteyn Date: Thu, 8 Oct 2026 13:22:26 -0400 Subject: [PATCH 2/2] Include independent raw RNA calls in support tiers --- README.md | 6 ++++-- pipeline/build_site.py | 25 +++++++++++++++++-------- tests/test_vaccine_peptide_analysis.py | 17 +++++++++++++++++ web/app.js | 2 +- web/index.html | 5 +++-- web/peptides.html | 5 +++-- 6 files changed, 45 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 0b0c1f9..852fd73 100644 --- a/README.md +++ b/README.md @@ -80,8 +80,10 @@ below the sequences; other source libraries are available in a separate disclosu 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 +zero, and unknown counts. This uses the maximum of Sid’s count and distinct +Exacto raw-read allele-call inputs per benchmark library at any timepoint, never +a sum across libraries or methods. Assembly and corrected counts do not raise +this tier; per-sample evidence retains both underlying counts. The main page retains the overall score and shows recovery within each evidence group; amino-acid failures name the expected and observed residues inline. diff --git a/pipeline/build_site.py b/pipeline/build_site.py index c271e95..8c6cdd1 100644 --- a/pipeline/build_site.py +++ b/pipeline/build_site.py @@ -63,7 +63,7 @@ def source_rna_evidence(variant: dict) -> list[dict]: ] -def rna_support_tier(evidence: list[dict]) -> dict: +def rna_support_tier(evidence: list[dict], recovery: dict | None = None) -> dict: """Maximum observed mutant count, never pooled across libraries or methods.""" def classify(entries): counts = [entry["alt_reads"] for entry in entries @@ -72,14 +72,23 @@ def classify(entries): 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} + by_sample = {} + for sample in SAMPLES: + sid = classify([entry for entry in evidence if entry.get("benchmark_sample") == sample.name]) + raw = (recovery or {}).get("samples", {}).get(sample.name, {}).get("arms", {}).get("reads", {}) + calls = raw.get("rna_variant_calls") + # Only the raw-read arm is one input RNA per transcript model. Corrected + # and assembled inputs reuse those reads and must not raise this tier. + exacto_count = len({call["transcript_model_id"] for call in calls}) if calls is not None else None + by_sample[sample.name] = { + **classify([{"alt_reads": sid["max_alt_reads"]}, {"alt_reads": exacto_count}]), + "sid_alt_reads": sid["max_alt_reads"], "exacto_raw_rnas": exacto_count, + } return { - **classify([entry for entry in evidence if entry.get("benchmark_sample")]), + **classify([{"alt_reads": counts["max_alt_reads"]} for counts in by_sample.values()]), "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", + "basis": "Maximum of Sid mutant reads and distinct Exacto raw-read allele-call inputs per benchmark library; never summed", } @@ -103,7 +112,7 @@ def peptide_report_with_rna_evidence(report: dict, catalogue: dict, results: dic for arm, entry in values.get("arms", {}).items() ] variant["source_rna_support"] = source_rna_evidence(by_id[variant["variant_id"]]) - variant["rna_support"] = rna_support_tier(variant["source_rna_support"]) + variant["rna_support"] = rna_support_tier(variant["source_rna_support"], scored.get(variant["variant_id"])) return report @@ -311,7 +320,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)), + "rna_support": rna_support_tier(source_rna_evidence(variant), recovery), } ) diff --git a/tests/test_vaccine_peptide_analysis.py b/tests/test_vaccine_peptide_analysis.py index 195b01b..448350d 100644 --- a/tests/test_vaccine_peptide_analysis.py +++ b/tests/test_vaccine_peptide_analysis.py @@ -165,3 +165,20 @@ def test_rna_support_tiers_use_maximum_without_pooling_or_imputing_missing_sampl 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" + + +def test_raw_rna_evidence_promotes_a_tier_without_double_counting_calls_or_assemblies(): + evidence = [{"benchmark_sample": "T2-ONT", "alt_reads": 1}] + calls = [{"transcript_model_id": "read-a"}, {"transcript_model_id": "read-a"}] + recovery = {"samples": {"T1-PacBio": {"arms": { + "reads": {"rna_variant_calls": calls}, + "corrected": {"rna_variant_calls": [{"transcript_model_id": str(i)} for i in range(20)]}, + }}}} + assert build_site.rna_support_tier(evidence, recovery)["category"] == "single" + calls.append({"transcript_model_id": "read-b"}) + tier = build_site.rna_support_tier(evidence, recovery) + assert tier["category"] == "multiple" + assert tier["max_alt_reads"] == 2 + assert tier["by_sample"]["T1-PacBio"]["sid_alt_reads"] is None + assert tier["by_sample"]["T1-PacBio"]["exacto_raw_rnas"] == 2 + assert tier["by_sample"]["T2-ONT"]["category"] == "single" diff --git a/web/app.js b/web/app.js index 3e71093..58e681a 100644 --- a/web/app.js +++ b/web/app.js @@ -264,7 +264,7 @@ function renderEvidenceBreakdown() { 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))); + ["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); diff --git a/web/index.html b/web/index.html index 07a1a57..1116330 100644 --- a/web/index.html +++ b/web/index.html @@ -55,8 +55,9 @@

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 +

2+ means at least two mutant reads in one benchmark library at any + timepoint. We use the larger of Sid’s count and Exacto’s distinct raw-read allele calls per library. + 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.

diff --git a/web/peptides.html b/web/peptides.html index 8331d6d..9a602f9 100644 --- a/web/peptides.html +++ b/web/peptides.html @@ -58,9 +58,10 @@

Per-sequence report

Sid counts are independent mutant / total reads at the locus: green means mutant reads; amber means covered with zero mutant reads; 0 / 0 means no coverage; — means not reported. Locus support does not establish full peptide recovery. Libraries stay separate.

-

RNA support groups use the maximum Sid mutant-read count in a benchmark-matched library: +

RNA support groups use the larger of Sid’s mutant-read count and Exacto’s distinct raw-read + allele calls in each benchmark library: 2+ in any library, single-read evidence, observed zero, or unknown. Sample filtering uses that - sample's library; methods do not change independent Sid counts. Missing libraries remain unknown. + sample's library; method filtering does not change these independent evidence groups. Missing libraries remain unknown. Two reads are a support threshold, not a guarantee of complete vaccine-sequence recovery.