diff --git a/CLAUDE.md b/CLAUDE.md index e5d4a2a..62867ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ ClickHouse SQL; clients never send SQL. Live at https://cbiolab.vercel.app (UI a error for a kind known only at run time is `plainResult(kind, {…})`. **Kinds never import each other:** a kind's runner, figure and `-.ts` modules import only one another and the shared modules (`lib/views/`: run, shared, rows, covariates; `lib/figures/`: common, volcano, bars, - forest) — `local/kind-boundary` in `eslint.config.mjs` enforces it; what two kinds need moves + forest, km) — `local/kind-boundary` in `eslint.config.mjs` enforces it; what two kinds need moves into a shared module, listed there. Kinds registered through `drawn()` have their figure drawn from the result and a throw reported as the view's error. - **`lib/oncoprint.ts`** — spec → rows (ClickHouse fetch); **`lib/oncoprint-assemble.ts`** — rows → diff --git a/app/globals.css b/app/globals.css index b59dcb4..90fe31d 100644 --- a/app/globals.css +++ b/app/globals.css @@ -31,6 +31,7 @@ --color-border: var(--line); --color-input: var(--line); --color-ring: var(--accent); --color-paper: var(--paper); --color-link: var(--accent-ink); --color-ok: var(--ok); --color-ok-soft: var(--ok-soft); + --color-new: var(--new); --color-mark: var(--mark); --color-mark-soft: var(--mark-soft); --color-amp: var(--amp); --color-del: var(--del); --color-white: #fff; --color-black: #000; --color-transparent: transparent; --color-current: currentColor; @@ -89,6 +90,8 @@ --accent: #0f86a1; --accent-ink: #0b6478; --accent-soft: #e3f0f3; --line: #dde4e8; --code-bg: #f1f5f7; --mark: #b25e09; --mark-soft: #fbf0e2; --ok: #2e7d32; --ok-soft: #e6f2e6; --err: #b3261e; --err-soft: #fbeae9; + /* the NEW mark's text (app/lab/new-mark.tsx) */ + --new: #ea580c; /* copy-number calls as text: amplification red, deep deletion blue */ --amp: #c62828; --del: #1f5fbf; @@ -114,6 +117,7 @@ --accent: #45b8c2; --accent-ink: #7fd3da; --accent-soft: #16333a; --line: #2a353c; --code-bg: #1a242a; --mark: #e0a35c; --mark-soft: #35270f; --ok: #7bc47f; --ok-soft: #17301a; --err: #f2b8b5; --err-soft: #3a2323; + --new: #fb923c; --amp: #ff8a80; --del: #82b1ff; --on-accent: #0d1417; } @@ -669,8 +673,8 @@ main.analysis-main { max-width: min(132rem, 100%); } .extag.on { background: var(--accent-ink); border-color: var(--accent-ink); color: var(--bg); } .extag.on .meta { color: var(--bg); opacity: 0.8; } .extag.is-static { cursor: default; } -.exfacet { align-items: baseline; } -.exfacet-l { font-size: var(--t-xs); color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; min-width: 3.6rem; } +.exfacet { margin-top: var(--s4); } +.exfacet-l { font-size: var(--t-sm); font-weight: 700; color: var(--ink); text-transform: uppercase; letter-spacing: 0.06em; padding-bottom: var(--s1); border-bottom: 1px solid var(--line); } .exempty { color: var(--muted); } .vkind { font-size: var(--t-xs); padding: 0.1rem 0.45rem; border-radius: var(--r-pill); white-space: nowrap; diff --git a/app/lab/analysis-sections.tsx b/app/lab/analysis-sections.tsx index c59d26a..6b91bb8 100644 --- a/app/lab/analysis-sections.tsx +++ b/app/lab/analysis-sections.tsx @@ -34,12 +34,17 @@ import type { DocStore } from "./doc-store.ts"; import type { SavedCohort, SavedWorkspace } from "./use-saved.ts"; import type { ExploreResult } from "../../lib/types.ts"; import type { ViewCtx } from "./view-cards.tsx"; +import { isNewKind } from "../../lib/new-in-lab.ts"; +import { NewMark } from "./new-mark.tsx"; /** A view kind already in the document: filled with the accent colour, so "on" cannot be mistaken * for a chip still loading (a pale pulsing fill). */ const VIEW_ON = "aria-pressed:bg-primary aria-pressed:text-primary-foreground hover:aria-pressed:bg-primary/90 " + "hover:aria-pressed:text-primary-foreground dark:aria-pressed:bg-primary dark:hover:aria-pressed:bg-primary/90"; +/** A kind not yet in the document: a grey fill, so each pill reads apart from its neighbours on the white card. */ +const ADD_PILL = "border-transparent bg-border text-foreground hover:bg-border/70"; + /** What the Analysis tab's sections read from the page and the actions they can take; the page builds one per render. */ export interface AnalysisCtx { VIEW_KINDS: { kind: string; label: string; why?: () => string | null; loading?: () => boolean; }[]; @@ -448,7 +453,7 @@ export function ViewColumn({ ctx }: { ctx: AnalysisCtx }) { if (present) return ( + onClick={() => removeViews((v) => v.kind === kind)}>{label}{isNewKind(kind) && }{present > 1 && " ×" + present} ); const full = (doc.views || []).length >= 6; // with no cohorts yet there is no study to ask about: the view's cohorts decide later @@ -458,10 +463,10 @@ export function ViewColumn({ ctx }: { ctx: AnalysisCtx }) { : waiting ? "loading this study's catalog — a moment" : (known && why ? why() : null); return ( - + onClick={() => addView(kind)}>{label}{isNewKind(kind) && } ); })} diff --git a/app/lab/examples-tab.tsx b/app/lab/examples-tab.tsx index 80e99fe..836358f 100644 --- a/app/lab/examples-tab.tsx +++ b/app/lab/examples-tab.tsx @@ -18,14 +18,21 @@ import { parseExamplesUrl } from "../../lib/site.ts"; import type { CohortDef } from "../../lib/types.ts"; import { Tabs, TabsList, TabsTrigger } from "../_ui/tabs"; import CohortPills from "./pills"; +import { isNewKind } from "../../lib/new-in-lab.ts"; +import { NewMark } from "./new-mark.tsx"; /** a tag filter: an outline chip, filled while it is the one shown */ const TAG = "group rounded-pill px-2.5 text-sm font-normal aria-pressed:border-link aria-pressed:bg-link aria-pressed:text-card aria-pressed:hover:bg-link/90 aria-pressed:hover:text-card"; +/** the views and topics lists: links flowing down columns, the chosen one in bold */ +const FACET_COLS = "mt-1 columns-[11rem] gap-x-6"; +const FACET_ITEM = "items-baseline text-sm font-normal aria-pressed:font-semibold aria-pressed:text-foreground aria-pressed:underline"; const TAG_N = "text-muted-foreground group-aria-pressed:text-card/80"; const FEATURES = new Map(EXAMPLES.map(e => [e, exampleFeatures(e)])); const byName = (a: string, b: string) => a.localeCompare(b, undefined, { sensitivity: "base" }); const VIEWS = [...new Set([...FEATURES.values()].flatMap(f => f.views))].sort(byName); const TOPICS = [...new Set([...FEATURES.values()].flatMap(f => f.topics))].sort(byName); +/** chips (a view kind, or a topic) under which at least one example draws a NEW view */ +const HAS_NEW = new Set([...FEATURES.values()].filter(f => f.views.some(isNewKind)).flatMap(f => [...f.views.filter(isNewKind), ...f.topics])); const has = (ex: DslExample, t: string) => { const f = FEATURES.get(ex)!; return f.views.includes(t) || f.topics.includes(t); }; const hostOf = (url: string) => { try { return new URL(url).host; } catch { return url; } }; @@ -85,12 +92,14 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els useEffect(() => () => onQuery(latest.current), [onQuery]); const searched = EXAMPLES.filter(e => matchesSearch(e, draft)); const shown = tag === "all" ? searched : searched.filter(e => has(e, tag)); - const chip = (t: string) => { + const item = (t: string) => { const n = searched.filter(e => has(e, t)).length; return ( - +
+ +
); }; @@ -132,8 +141,8 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els all {searched.length} -
views{VIEWS.map(chip)}
-
topics{TOPICS.map(chip)}
+
views
{VIEWS.map(item)}
+
topics
{TOPICS.map(item)}
{shown.length === 0 && ( @@ -152,7 +161,7 @@ export function ExamplesTab({ tag, onTag, query, onQuery, busy, onRun, link, els const { views, topics } = FEATURES.get(ex)!; return (
-

{ex.title}

+

{ex.title}{views.some(isNewKind) && }

{views.map(k => {k})} {topics.map(t => {t})} diff --git a/app/lab/kind-cards.tsx b/app/lab/kind-cards.tsx index 2e16606..2e5ff75 100644 --- a/app/lab/kind-cards.tsx +++ b/app/lab/kind-cards.tsx @@ -80,9 +80,35 @@ export const KIND_CARDS: Record = { why: (k) => k.events.some((e) => /lab/i.test(e.canonical)) ? null : "this study records no lab-test timeline events" }, incidence: { label: "Cumulative incidence (competing risks)", title: "Cumulative incidence", maxCohorts: 6, why: (k) => k.events.length ? null : "this study records no timeline events to estimate the incidence of" }, + hla: { label: "HLA class I genotype (homozygosity, HLA LOH and OS)", title: "HLA class I genotype", maxCohorts: 3, + why: (k) => k.attrs.some((a) => /^hla[-_]?[abc]/i.test(a.attribute)) ? null : "this study records no HLA class I alleles" }, + cutpoint: { label: "Biomarker cut-point (best split of a number for OS)", title: "Cut-point", maxCohorts: 1, + why: (k) => k.attrs.some((a) => a.kind === "num") ? null : "this study has no numeric clinical attribute to find a cut-point of" }, + predictive: { label: "Predictive biomarkers (gene × treatment interaction)", title: "Predictive biomarkers", genes: true, maxCohorts: 2, + why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events to define an exposure" }, + rmst: { label: "Restricted mean OS time (RMST, landmarks)", title: "Restricted mean OS time", maxCohorts: 6, + why: (k) => k.attrs.some((a) => /_MONTHS$/i.test(a.attribute)) ? null : "this study has no _MONTHS outcome column" }, + multistate: { label: "Multistate model (progression, then death)", title: "Multistate model", maxCohorts: 4, + why: (k) => k.events.some((e) => /diagnos|progress/i.test(e.canonical)) ? null : "this study records no diagnosis or progression timeline events" }, + txendpoints: { label: "Treatment endpoints (TTNT, time on treatment, real-world PFS)", title: "Treatment endpoints", maxCohorts: 6, + why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events" }, clonality: { label: "Clonality (FACETS)", title: "Clonality", genes: true, maxCohorts: 3 }, paired: { label: "Paired samples (primary vs metastasis, before vs after)", title: "Paired samples", genes: true, maxCohorts: 3 }, + acquired: { label: "Acquired under therapy (samples before vs after a treatment start)", title: "Acquired under therapy", genes: true, maxCohorts: 3, + why: (k) => k.events.some((e) => /treat|therap|drug|medicat/i.test(e.canonical)) ? null : "this study records no treatment timeline events to anchor on" }, + instability: { label: "Genomic instability (aneuploidy score, FGA, arm-level calls)", title: "Genomic instability", maxCohorts: 6 }, + exclusivity: { label: "Mutual exclusivity (DISCOVER, burden-aware)", title: "Mutual exclusivity", genes: true, maxCohorts: 1, cohortInHeader: true }, + ctdna: { label: "ctDNA vs tissue (plasma against tissue, OS by ctDNA detected)", title: "ctDNA vs tissue", genes: true, maxCohorts: 3 }, riskmodel: { label: "Risk model (elastic-net Cox, OncoCast-style)", title: "Risk model", maxCohorts: 1 }, + drivernegative: { label: "Driver-negative analysis (genes enriched without a canonical driver)", title: "Driver-negative analysis", maxCohorts: 1 }, + organotropism: { label: "Metastatic organotropism (genes × metastatic sites, within cancer types)", title: "Metastatic organotropism", maxCohorts: 1, cohortInHeader: true, + why: (k) => k.attrs.some((a) => /^DMETS_DX_|^METASTATIC_SITE$/i.test(a.attribute)) || k.events.some((e) => /diagnosis/i.test(e.canonical)) ? null + : "this study records no metastatic sites (per-organ DMETS_DX_ flags, METASTATIC_SITE, or tumour sites on a Diagnosis timeline)" }, + germline: { label: "Germline carriers (and their somatic second hits)", title: "Germline carriers", maxCohorts: 4 }, + molsubtype: { label: "Molecular subtypes (endometrial POLE / MMRd / p53abn / NSMP)", title: "Molecular subtypes", maxCohorts: 6 }, + actionability: { label: "Clinical actionability (OncoKB levels, primary vs metastasis)", title: "Clinical actionability", maxCohorts: 4 }, + adjusted: { label: "Adjusted enrichment (every gene, logistic on covariates)", title: "Adjusted enrichment", maxCohorts: 2, minCohorts: 2 }, + forest: { label: "Pan-cancer forest (one exposure's HR per cancer type, random-effects pooled)", title: "Forest plot", maxCohorts: 6 }, diffexpr: { label: "Differential expression + GSEA", title: "Differential expression", maxCohorts: 2, minCohorts: 2, why: (k) => !k.profiles.length ? "this study has no molecular profile to compare expression on" : null } }; @@ -148,6 +174,11 @@ function RiskModelSettings({ vi, spec, busy, patch }: { vi: number; spec: any; b +
+ validate on + patch({ validateOn: t.trim() || undefined })} /> +
); } @@ -189,7 +220,25 @@ export function kindSub(kind: string, spec: any, v: any): React.ReactNode { if (kind === "riskmodel") return (spec.endpoint || "OS") + (spec.anchor?.eventType ? " from first " + spec.anchor.eventType + (spec.anchor.key ? " [" + spec.anchor.key + "=" + spec.anchor.value + "]" : "") : "") + " on " + (spec.measure === "driver" ? "driver mutations" : "mutated genes") + (spec.covariates?.length ? " + " + spec.covariates.length + " covariate" + (spec.covariates.length === 1 ? "" : "s") : "") + - (spec.strata ? ", stratified by " + spec.strata : "") + " · " + (spec.runs ?? 10) + " held-out splits" + (spec.validate ? ", validated on the held-out half" : ""); + (spec.strata ? ", stratified by " + spec.strata : "") + " · " + (spec.runs ?? 10) + " held-out splits" + (spec.validate ? ", validated on the held-out half" : "") + + (spec.validateOn ? ", scored on “" + spec.validateOn + "”" : ""); + if (kind === "drivernegative") return (spec.alteration === "sv" ? "structural variants" : spec.alteration === "cna" ? "copy-number alterations" : "mutations") + + " without vs with a driver in " + (spec.drivers?.length ? spec.drivers.length + " genes" : "the NSCLC set") + ", per patient"; + if (kind === "organotropism") return (spec.measure === "any" ? "" : "driver ") + (spec.alteration || "mutation") + "s in " + + (spec.genes?.length ? spec.genes.length + " genes" : "the " + (spec.top ?? 12) + " most altered genes") + " × metastatic sites from " + (spec.source && spec.source !== "auto" ? spec.source : "the study's site source") + + (spec.stratifyBy === null ? ", unstratified" : ", within " + (spec.stratifyBy || "CANCER_TYPE")); + if (kind === "germline") return "germline carriers per patient sequenced, " + (spec.genes?.length ? spec.genes.length + " genes" : "every gene with ≥" + (spec.minCarriers ?? 3) + " carriers") + + " · somatic second hit vs non-carriers" + (spec.survival?.gene ? " · OS by " + spec.survival.gene + " carrier status" : ""); + if (kind === "molsubtype") return "endometrial molecular classification per patient (POLE, MMRd, p53abn, NSMP)" + (spec.by ? " · by " + spec.by : "") + + (spec.survival === false ? "" : " · OS by subtype"); + if (kind === "actionability") return "highest OncoKB level per patient, any cancer type" + (spec.groupBy ? " · by " + spec.groupBy : "") + + (spec.pairBy === null ? "" : " · " + (spec.pairBy?.attribute || "SAMPLE_TYPE") + " concordance"); + if (kind === "adjusted") return "mutations in every gene mutated in ≥" + (100 * (spec.minShare ?? 0.05)).toFixed(0) + "% · adjusted for " + (spec.covariates?.length ? spec.covariates.join(", ") : "nothing"); + if (kind === "forest") { + const e = spec.exposure || {}; + const what = e.source === "alteration" ? (e.oql || (e.genes || [e.gene]).join(" or ") + " mutated") : e.attribute ? e.attribute + (spec.threshold != null ? " ≥ " + spec.threshold : spec.exposed ? " = " + spec.exposed : "") : "no exposure chosen"; + return what + " · " + (spec.endpoint || "OS") + " hazard ratio per " + (spec.strata || "CANCER_TYPE") + ", random-effects pooled"; + } if (kind === "labs") { const an = spec.anchor || {}; return (spec.test || "no test chosen") + " from first " + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + @@ -201,6 +250,29 @@ export function kindSub(kind: string, spec: any, v: any): React.ReactNode { return (e.label || e.eventType || "no event chosen") + (an && an.eventType ? " from first " + an.eventType + (an.key ? " [" + an.key + "=" + an.value + "]" : "") : " from day 0") + ", death competing"; } + if (kind === "hla") return "alleles at " + (spec.resolution === 1 ? "1 field" : spec.resolution === "full" ? "full resolution" : "2 fields") + " · " + (spec.endpoint || "OS") + + " by homozygosity and by HLA LOH" + (spec.covariates && spec.covariates.length ? " · adjusted for " + spec.covariates.join(", ") : ""); + if (kind === "cutpoint") return (spec.attribute || "TMB_NONSYNONYMOUS") + " on " + (spec.endpoint || "OS") + " · best log-rank cut, " + (spec.permutations ?? 1000) + " permutations" + + (spec.stratifyBy ? " · per " + spec.stratifyBy : ""); + if (kind === "predictive") { + const ex = spec.exposure || {}; + return "gene × " + (ex.eventType || "?") + (ex.key ? " [" + ex.key + "=" + ex.value + "]" : "") + " on " + (spec.endpoint || "OS") + + (spec.covariates?.length ? " + " + spec.covariates.length + " covariate" + (spec.covariates.length === 1 ? "" : "s") : "") + " · Cox per gene"; + } + if (kind === "rmst") { + const lm = Array.isArray(spec.landmarkMonths) && spec.landmarkMonths.some((x: number) => x > 0) ? " · landmarks " + spec.landmarkMonths.join(", ") + " mo" : ""; + return "area under the KM curve to " + (spec.tau != null ? "τ = " + spec.tau + " months" : "τ where every cohort has 10 at risk") + " · " + (spec.anchor?.eventType ? "OS from first " + spec.anchor.eventType + (spec.anchor.key ? " [" + spec.anchor.key + "=" + spec.anchor.value + "]" : "") : (spec.endpoint || "OS")) + lm; + } + if (kind === "multistate") { + const pr = spec.progression || {}; + return "start → progression → death, months from day 0 · progression = " + (pr.eventType || "Diagnosis") + " " + (pr.key || "PROGRESSION") + + (spec.prevalent === "exclude" ? " · progressed by day 0 left out" : ""); + } + if (kind === "txendpoints") { + const an = spec.anchor || {}; + const ep = spec.endpoint === "tot" ? "time on treatment" : spec.endpoint === "rwpfs" ? "real-world PFS" : "time to next treatment"; + return ep + " from " + (an.occurrence === "LAST" ? "last " : "first ") + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + ", death an event"; + } if (kind === "clonality") return "FACETS allele-specific copy number" + (spec.measure === "any" ? ", every mutation" : ", driver mutations") + (spec.qc === false ? ", all samples" : ", QC-passing samples"); if (kind === "paired") { const pb = spec.pairBy || { attribute: "SAMPLE_TYPE", a: "Primary", b: "Metastasis" }; @@ -209,6 +281,21 @@ export function kindSub(kind: string, spec: any, v: any): React.ReactNode { : pb.studies ? "sides set per study" : pb.attribute + ": " + j(pb.a) + " vs " + j(pb.b); return sides + (spec.measure === "any" ? ", any alteration" : ", drivers") + (spec.genes && spec.genes.length ? "" : ", every gene scanned"); } + if (kind === "acquired") { + const an = spec.anchor || { eventType: "Treatment" }; + return "last sample before the first " + (an.eventType || "?") + (an.key ? " [" + an.key + "=" + an.value + "]" : "") + " vs the first ≥" + (spec.minGapDays ?? 30) + " days after" + + (spec.measure === "driver" ? ", oncogenic only" : ", any alteration") + (spec.genes && spec.genes.length ? "" : ", every gene scanned"); + } + if (kind === "instability") return "from hg19 segments, ±" + (spec.threshold ?? 0.2) + ", arm called past " + Math.round(100 * (spec.armShare ?? 0.5)) + "% of its length" + (spec.includeX ? ", with chrX" : ""); + if (kind === "exclusivity") { + const cohort = Array.isArray(v.cohorts) && v.cohorts[0] ? String(v.cohorts[0]).split(" / ").join(" › ") : null; + return <>{cohort && <>{cohort} · }{(spec.measure === "driver" ? "oncogenic alterations" : "any alteration") + ", background from each tumour's burden" + + (spec.stratifyBy ? ", within " + spec.stratifyBy : "")}; + } + if (kind === "ctdna") return "plasma " + (spec.attribute || "SAMPLE_CLASS") + " = " + (spec.plasma || ["cfDNA", "ctDNA"]).join("/") + " vs tissue " + (spec.tissue || ["Tumor"]).join("/") + + (spec.measure === "driver" ? ", oncogenic only" : ", any alteration") + (spec.withinDays != null ? ", drawn within " + spec.withinDays + " days" : "") + + (spec.chFilter ? ", clonal haematopoiesis filtered" : "") + + (spec.genes && spec.genes.length ? "" : ", the most altered genes"); if (kind === "diffexpr") return (spec.profileType || "no profile") + (v.diffexpr ? (v.diffexpr.log2 ? ", log2(v+1)" : "") + " · volcano and GSEA" : ""); if (kind === "pathways") return (spec.measure === "any" ? "any alteration" : "OncoKB drivers") + ", Sanchez-Vega et al. 2018"; if (kind === "genome") return v.genome ? v.genome.build + ", " + v.genome.binMb + " Mb bins, from segments" : "from copy-number segments"; @@ -490,6 +577,212 @@ export function KindCardControls({ c }: { c: KindCardCtx }) {
); } + if (spec.kind === "germline") { + const genes: string[] = Array.isArray(spec.genes) ? spec.genes : []; + return ( +
+
data + + + +
+
+ ); + } + if (spec.kind === "molsubtype") { + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const by = spec.by || ""; + return ( +
+
data + + + +
+
+ ); + } + if (spec.kind === "organotropism") { + const genes: string[] = Array.isArray(spec.genes) ? spec.genes : []; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const strat = spec.stratifyBy === null ? "" : spec.stratifyBy || "CANCER_TYPE"; + return ( +
+
data + + + + +
+
+ ); + } + if (spec.kind === "drivernegative") { + const drv: string[] = Array.isArray(spec.drivers) ? spec.drivers : []; + return ( +
+
data + + + +
+
+ ); + } + if (spec.kind === "actionability") { + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const by = spec.groupBy || ""; + return ( +
+
data + + + +
+
+ ); + } + if (spec.kind === "adjusted") { + const covs: string[] = Array.isArray(spec.covariates) ? spec.covariates : []; + return ( +
+
data + + + +
+
+ ); + } + if (spec.kind === "forest") { + const e = spec.exposure || { source: "alteration", gene: "" }; + const isGene = e.source === "alteration"; + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const strata = spec.strata || "CANCER_TYPE"; + return ( +
+
exposure + + {isGene + ? patch({ exposure: { source: "alteration", gene: t.trim().toUpperCase() } })} /> + : <> + patch({ exposure: { source: "clinical", attribute: t.trim() } })} /> + + + } +
+
rows + + + +
+
+ ); + } if (spec.kind === "labs") { const an = spec.anchor || { eventType: "" }; const types = c.events.map((x) => x.label); @@ -529,6 +822,165 @@ export function KindCardControls({ c }: { c: KindCardCtx }) {
); } + if (spec.kind === "hla") { + const nums = c.attrs.filter((a) => a.kind === "num").map((a) => a.attribute); + const cov = (spec.covariates || [])[0] || ""; + return ( +
+
data + + +
+
+ ); + } + if (spec.kind === "cutpoint") { + const nums = c.attrs.filter((a) => a.kind === "num").map((a) => a.attribute); + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + const attr = spec.attribute || "TMB_NONSYNONYMOUS"; + return ( +
+
data + + + +
+
+ ); + } + if (spec.kind === "predictive") { + const ex = spec.exposure || { eventType: "Treatment" }; + const types = c.events.map((x) => x.label); + return ( +
+
exposure + + + +
+
+ ); + } + if (spec.kind === "rmst") { + const lms = Array.isArray(spec.landmarkMonths) ? spec.landmarkMonths.join(", ") : "0"; + return ( +
+
restrict + + +
+
+ ); + } + if (spec.kind === "multistate") { + const pr = spec.progression || {}; + const types = c.events.map((x) => x.label); + const et = pr.eventType || "Diagnosis"; + return ( +
+
progression + + +
+
day 0 + +
+
+ ); + } + if (spec.kind === "txendpoints") { + const an = spec.anchor || { eventType: "Treatment" }; + const types = c.events.map((x) => x.label); + return ( +
+
endpoint + + +
+
clock + + + +
+
+ ); + } if (spec.kind === "incidence") { const e = spec.event || { eventType: "" }, a0 = (e.attrs || [])[0] || { key: "", value: "" }; const an = spec.anchor || null; @@ -626,6 +1078,127 @@ export function KindCardControls({ c }: { c: KindCardCtx }) { ); } + if (spec.kind === "acquired") { + const an = spec.anchor || { eventType: "Treatment" }; + const types = c.events.map((x) => x.label); + const scanning = !(spec.genes && spec.genes.length); + return ( +
+
clock + + + + +
+
data + + + +
+
+ ); + } + if (spec.kind === "instability") return ( +
+
data + + + +
+
+ ); + if (spec.kind === "exclusivity") { + const cats = c.attrs.filter((a) => a.kind !== "num").map((a) => a.attribute); + return ( +
+
data + + +
+
+ ); + } + if (spec.kind === "ctdna") { + const choosing = !!(spec.genes && spec.genes.length); + const csv = (x: any, d: string[]) => (Array.isArray(x) && x.length ? x : d).join(", "); + const parse = (t: string) => t.split(",").map((y) => y.trim()).filter(Boolean); + return ( +
+
data + + + + + +
+
+ ); + } if (spec.kind === "pathways") return (
data @@ -648,16 +1221,49 @@ export function KindCardControls({ c }: { c: KindCardCtx }) { /** The view a fresh "add view" click starts from, built from what the study is known to have. */ export function defaultKindView(kind: string, known: Known): Record { if (kind === "cancertypes") return { kind, genes: known.genes.length ? known.genes.slice(0, 5) : ["TP53", "KRAS"] }; + if (kind === "adjusted") return { kind }; + if (kind === "actionability") return { kind, groupBy: "CANCER_TYPE" }; + if (kind === "drivernegative") return { kind }; + if (kind === "organotropism") return { kind }; + if (kind === "molsubtype") return { kind }; + if (kind === "germline") return { kind }; + if (kind === "forest") return { kind, exposure: { source: "alteration", gene: known.genes[0] || "TP53" } }; if (kind === "labs") { const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); return { kind, test: "PSA", anchor: { eventType: tx ? tx.label : "Treatment" } }; } + if (kind === "hla") { + const tmb = known.attrs.find((a) => a.attribute === "TMB_NONSYNONYMOUS" || a.attribute === "TMB_SCORE"); + return tmb ? { kind, covariates: [tmb.attribute] } : { kind }; + } + if (kind === "cutpoint") { + const num = known.attrs.find((a) => a.attribute === "TMB_NONSYNONYMOUS") || known.attrs.find((a) => a.kind === "num"); + return { kind, attribute: num ? num.attribute : "TMB_NONSYNONYMOUS" }; + } + if (kind === "predictive") { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind, exposure: { eventType: tx ? tx.label : "Treatment", key: "SUBTYPE", value: "Immuno" }, genes: known.genes.length ? known.genes.slice(0, 8) : ["STK11", "KEAP1", "KRAS", "TP53", "EGFR"] }; + } + if (kind === "multistate") { + const dx = known.events.find((e) => /diagnos/i.test(e.canonical)); + return { kind, progression: { eventType: dx ? dx.label : "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"] } }; + } + if (kind === "txendpoints") { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind, endpoint: "ttnt", anchor: { eventType: tx ? tx.label : "Treatment", key: "SUBTYPE", value: "Immuno" } }; + } if (kind === "incidence") { const ev = known.events.find((e) => /diagnos|progress|status/i.test(e.canonical)) || known.events[0]; return { kind, event: { eventType: ev ? ev.label : "" } }; } if (kind === "clonality") return { kind, genes: known.genes.length ? known.genes.slice(0, 8) : ["TP53", "KRAS", "APC", "PIK3CA", "SMAD4", "CDH1"] }; if (kind === "paired") return { kind, genes: known.genes.length ? known.genes.slice(0, 8) : PAIRED_DEFAULT_GENES }; + if (kind === "acquired") { + const tx = known.events.find((e) => /treat|therap|drug|medicat/i.test(e.canonical)); + return { kind, anchor: { eventType: tx ? tx.label : "Treatment" } }; + } + if (kind === "exclusivity") return { kind, genes: known.genes.length >= 2 ? known.genes.slice(0, 8) : ["KRAS", "EGFR", "BRAF", "STK11", "KEAP1", "TP53"] }; + if (kind === "ctdna") return known.genes.length ? { kind, genes: known.genes.slice(0, 10) } : { kind }; if (kind === "mutations") return { kind, gene: known.genes[0] || "TP53" }; if (kind === "table") return { kind }; if (kind === "timeline" || kind === "alterations") return { kind }; diff --git a/app/lab/new-mark.tsx b/app/lab/new-mark.tsx new file mode 100644 index 0000000..01f5f28 --- /dev/null +++ b/app/lab/new-mark.tsx @@ -0,0 +1,6 @@ +// The NEW mark after a view kind's name: kinds cBioPortal has no counterpart for (lib/new-in-lab.ts). +import { NEW_TITLE } from "../../lib/new-in-lab.ts"; + +export function NewMark() { + return NEW; +} diff --git a/app/lab/view-chrome.tsx b/app/lab/view-chrome.tsx index 9d1f115..132f53d 100644 --- a/app/lab/view-chrome.tsx +++ b/app/lab/view-chrome.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { attributeLabel } from "../../lib/custom-data-names.ts"; import type { AnalysisSource, ViewParticipation } from "../../lib/types.ts"; import { VIEW_HELP } from "../../lib/view-help.ts"; +import { previewPending } from "../../lib/preview-kinds.ts"; import { SERIES, seriesStyle } from "../../lib/chart/style.ts"; import { Waiting } from "../_oncoprint/OncoprintCard.tsx"; import { ArrowDown, ArrowUp, Ellipsis, ExternalLink, ListChecks, Lock, LockOpen, Pencil, Plus, Save, Split, StickyNote, Trash2 } from "lucide-react"; @@ -16,6 +17,7 @@ import { OQL_STATEMENT_CAP } from "../../lib/plot-oql.ts"; import { plottableProfiles, profileGroup, profileUse, type ProfileInfo } from "../../lib/profile-catalog.ts"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "../_ui/select"; import { Button } from "../_ui/button"; +import { Badge } from "../_ui/badge"; import { cn } from "../../lib/cn"; import { OptionSelect } from "../_ui/choice.tsx"; import { FIELD } from "../_ui/toolbar.ts"; @@ -509,6 +511,7 @@ export function ViewHead({ v, title, sub, actions, chips, cohorts, cohortInSub, )} {actions} {chips} + {(onMoveUp || onMoveDown || onRemove || onEditNote) && ( @@ -627,6 +630,13 @@ export function CohortEditMenu({ busy, canEdit, onEdit, onRemove, split, splitCo } /** the server-side wall clock for one view, and what that number does and does not include */ +/** A preview kind's badge: its numbers are not yet checked by a method record (lib/preview-kinds.ts). */ +function PreviewBadge({ kind }: { kind: unknown }) { + const pending = previewPending(kind); + if (!pending) return null; + return Preview; +} + export function Latency({ v }: { v: any }) { if (v.ms == null) return null; return ( diff --git a/docs/dsl.md b/docs/dsl.md index 07c4513..83f9839 100644 --- a/docs/dsl.md +++ b/docs/dsl.md @@ -278,6 +278,8 @@ patient — so the arm counts summing to more than the base cohort is expected, | `survival` | `endpoint?:"OS"|"PFS"|…, monthsAttribute?/statusAttribute?, anchor?:{eventType,key?,value?,occurrence?}, cohorts:[names]` | Kaplan–Meier on OS_MONTHS/OS_STATUS, plus a two-sided **log-rank** test when exactly 2 cohorts, or the **k-group generalization** (a (k-1)-df chi-squared from the full covariance matrix, not a scalar variance — the same statistic R's `survdiff()` reports) when 3+, testing whether the groups differ AT ALL rather than which pair does. Across studies either test is **stratified by study** (each study compared only against itself, so differing case mix cannot masquerade as a cohort effect); the pooled p is reported alongside. `anchor` resets the clock to a timeline event (first chemotherapy, initial diagnosis) instead of the study's day 0, and **applies left truncation automatically**: a patient joins the risk set when the study could first observe them, never at the anchor — otherwise everyone appears to survive from the anchor to their own entry, which is exactly what put them in the study. The reported entry distribution warns when an anchor is matching earlier events than intended. Cross-study baselines are not harmonized. **The test is refused, not computed, when any arm has fewer than 10 patients** — the KM curves still draw (they are descriptive), but a chi-squared from a handful of patients reads as a real result whatever its p-value says, so it is named as refused rather than reported. `monthsAttribute`/`statusAttribute` override the `_MONTHS`/`_STATUS` naming convention with exact column names, for a study whose outcome columns don't follow it (some do not — e.g. `PFS_MONTHS_FIRST_LINE_TX`/`PROGRESSION_STATUS`); both must be given together, and `endpoint` becomes purely a display label rather than something column names are derived from | | `cox` | `endpoint?:"OS"|"PFS"|..., monthsAttribute?/statusAttribute?, covariates:[axis, ...(max 8)], stratifyByStudy?, stratifyByCohort?, cutoffMonths?, cohorts:[names]` | multivariate Cox proportional-hazards regression: several covariates fit simultaneously, reported as hazard ratios with Wald confidence intervals, drawn as a forest plot. Each `covariate` is a plot axis (clinical, molecular, or a gene's — or a gene SET's — alteration status; see Plot axes below). Unlike every other view, this is fit from real per-patient rows within each LOCAL study, not pooled aggregate counts, so a remote or aggregate-only participant cannot contribute to it and is named as excluded rather than silently dropped. `stratifyByStudy` fits a separate baseline hazard per study instead of pooling them (one model, per-study risk sets). `stratifyByCohort` goes further: with 2+ named cohorts, it fits each cohort as its own INDEPENDENT model — own coefficients, own reference-level choice — instead of one pooled fit, drawn as one forest-plot panel per cohort sharing a single hazard-ratio scale so the effects are directly comparable; the right tool when a covariate's EFFECT, not just the cohorts' case mix, might genuinely differ between them (pooling would average that difference away). A cohort that can't be fit (too few patients, no variation) is named and skipped rather than blocking the rest; a no-op with only one cohort. A categorical covariate is dummy-coded against a reference level: a mutated-vs-not alteration axis (`by:"mutated"`, the default) always defaults to `"Not mutated"` regardless of which side is more frequent — a gene altered in a minority of patients and one altered in a majority would otherwise silently end up measured in opposite directions, purely from how common each gene's alteration happens to be; every other categorical (a mutation-type/CNA axis, a clinical attribute) defaults to its most-frequently-observed level. Either way, a covariate axis may add `"reference":""` to declare the level explicitly instead — a `reference` naming a level the data doesn't have is ignored (falls back to the same default as if none were declared) rather than silently matching nothing. `monthsAttribute`/`statusAttribute` are the same naming-convention override as `survival`, for a study whose outcome columns don't follow `_MONTHS`/`_STATUS` | | `logistic` | `outcome:axis, outcomeEvent?:"", covariates:[axis, ...(max 8)], cohorts:[names]` | multivariate logistic regression: a BINARY outcome (not time-to-event — no endpoint/months convention here) regressed on several covariates at once, reported as odds ratios with Wald confidence intervals, drawn as a forest plot the same shape as `cox`'s. `outcome` is a plot axis, exactly like a covariate (clinical, molecular, or alteration status). A categorical outcome (a clinical attribute, or an alteration axis) needs `outcomeEvent` naming which observed value counts as the event (1) — every other observed value collapses into "not event" (0), and which values got collapsed is disclosed in the caveat; omitting it, or naming a value never observed, is refused with the actual observed values listed rather than guessed at. A numeric outcome must already be coded exactly 0/1 (refused otherwise) and must NOT be given `outcomeEvent`. Covariates use the exact same dummy-coding and reference-level rules as `cox` (see above): a mutated-vs-not axis defaults to `"Not mutated"`, others default to the most frequent level, `reference` overrides either. Same patient-level-only boundary as `cox` | +| `forest` | `exposure:axis, exposed?:"", threshold?:number, endpoint?:"OS"|…, monthsAttribute?/statusAttribute?, strata?:"CANCER_TYPE"||"study"|"cohort", covariates?:[axis ×≤4], minPatients?:30, minEvents?:10, maxStrata?:30, small?:"pool"|"drop", cohorts:[names]` | **a pan-cancer forest / random-effects meta-analysis of one exposure's hazard ratio**: the cohorts' patients (pooled; per cohort with `strata:"cohort"`) are split by `strata` — a categorical attribute (default CANCER_TYPE; a patient whose samples disagree is left out, counted), the study, or the view's cohorts — and within each a Cox model (Efron ties) is fit for exposed vs not, univariate or adjusted for `covariates` (coded as `cox` codes them, within each row). The exposure is a plot axis: a gene's alteration (exposed = altered; a patient not profiled for the gene is left out, never unaltered), a categorical attribute with `exposed` naming the level, or a numeric one with `threshold` (exposed at or above it). Strata under `minPatients` patients or `minEvents` events, and past the `maxStrata` largest, are pooled into one "Other" row (each keeping its own baseline hazard) or, with `small:"drop"`, listed as left out; a row without an exposed and an unexposed event, or whose fit does not converge, is listed with the reason. The rows are pooled by inverse variance on log HR: fixed effect, and DerSimonian–Laird random effects with τ², I², Cochran's Q and its p. Fit from per-patient rows inside each local study (patient-level participants only). | +| `adjusted` | `covariates?:["" ×≤6], measure?:"any"|"driver", minShare?:0.05, minAltered?:10, label?:12, cohorts:[2 names]` | **covariate-adjusted alteration enrichment between two cohorts**: every gene mutated (called mutations; OncoKB drivers with `measure:"driver"`) in at least `minShare` of the patients profiled for it and in at least `minAltered`, both cohorts together — at most 200 genes, refused past that with the count — gets a logistic regression of mutated on cohort (the first = 1) and the `covariates` (clinical attributes: a number as recorded, a category against its commonest value, as `cox`/`logistic` code them; a sample attribute is the patient's first sample's; patients lacking one are left out, counted). Reported: the cohort term's adjusted odds ratio, Wald 95% CI and p, Benjamini–Hochberg q over the genes fit, beside the raw odds ratio and two-sided Fisher p on the same patients. Per patient and per gene, panel-aware: a patient is in a gene's model only when a mutation panel of theirs covers it (or whole-exome). A covariate category in which no patient (or every patient) is mutated is merged into its reference for that gene; a gene whose fit still separates or does not converge is listed, never dropped. Volcano of adjusted log2 OR against −log₁₀ q, and the top genes' table. Patient-level participants only. | | `course` | `eventTypes:[≤6], anchor?:{eventType,key?,value?,occurrence?}, binMonths?:3, fromMonths?:-12, toMonths?:60, cohorts:[names]` | the **clinical course**: per time bin, the share of the cohort in each event state. Two things it refuses to fake. (1) The denominator is the patients still under observation in that bin — first recorded event to last contact (the later of their last event and their OS follow-up) — never the whole cohort, which would make a curve fall merely because follow-up ran out. (2) `anchor` is optional for one study but **required across studies**: day 0 is birth in some studies and first sequencing in others, and nothing in the schema records which, so raw day 0 compares incomparable clocks. Event types that carry real durations are drawn as a state the patient is IN; the rest as an event that HAPPENED — read from the data, not from the name. Bins are half-open [t, t+w). Event-name spellings differing only in case are folded (`Treatment`/`TREATMENT`) and the fold is named in the caveat; nothing else is merged | | `sequence` | `labelKey?:"SUBTYPE", eventType?:"treatment", lineClasses?:"systemic"|"all"|[classes], exclude?:[values], lineGap?:90, maxSteps?:3, topN?:8, cohorts:[names]` | **what follows what**: therapy transitions as lines, drawn as a Sankey. Records are first grouped into LINES — therapies less than `lineGap` days apart are one line, labelled by their whole combination (sorted, so `A + B` and `B + A` are one regimen). That grouping is the point: a patient has a median of 6 treatment records and 4 distinct agents, and combination therapy is several records describing ONE decision. Paths that end flow into TWO explicit terminals, never one: **no further line** (the study watched for at least one `lineGap` and saw no further therapy) and **record ends here** (it did not, so nothing can be concluded). Measured on MSK-CHORD, 68% of patients have under three months of observation after their last therapy and the median is 15 days — so collapsing the two would turn record-keeping into an apparent clinical decision. Flows out of a node sum to it, so nobody vanishes. Up to two cohorts are drawn as panels sharing one scale and one colour per regimen. Lines are the shared definition (see "Treatment line (derived)" above): systemic-therapy records by default, `lineClasses` `"all"` for every treatment record or a list of class names for your own; `labelKey` only names a line’s therapies (e.g. AGENT), it never decides which records form lines. `exclude` drops records by label value, matched ignoring case. More than two cohorts is refused | | `plot` | `x:axis, y:axis, unit?:"sample"\|"patient", bins?:[240,160], allPoints?:true, points?:[layer(max 4)], colorBy?:axis, ringBy?:axis, cohorts:[names]` | two axes over the same cases, aggregated **inside the database**: a scatter comes back as counts per grid cell, a box as quartiles per group, two categoricals as a contingency table. The payload is a few kilobytes whatever the cohort's size. A point is a sample, as on cBioPortal's Plots tab; `unit:"patient"` makes it a patient, combining a patient's samples: a numeric value is their mean, a category their common value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption says how many patients had more than one sample. Every case is a point up to 10,000 (`allPoints: false` turns that off), from a participant that may show cases; `points` lists up to four subsets drawn in colour over it — `{rule:"outliers"}` (the default), `{rule:"extremes",axis,n}`, `{rule:"trend",n}`, `{cohort}` — see **Points** below | @@ -286,13 +288,28 @@ patient — so the arm counts summing to more than the base cohort is expected, | `pathways` | `pathways?:[names], sets?:[{name, genes:[≤40]} ×≤4], measure?:"driver"|"any", topGenes?:6, cohorts:[names] (≤4)` | **oncogenic signalling pathways**: the ten canonical pathways of Sanchez-Vega et al. (Cell 2018, Table S3: RTK-RAS, PI3K, TP53, Cell cycle, MYC, WNT, NOTCH, Hippo, TGF-β, NRF2 — 334 genes), and/or the document's own `sets`, each as the share of the cohort with at least one member altered. `measure` defaults to `"driver"` (OncoKB-annotated drivers, as the paper counted curated oncogenic events; refused without an annotation pin) or `"any"`. A pathway's denominator is the patients profiled for at least ONE member (the union of the members' panels, the oncoprint's merged-row rule, computed in the database with the same event table, CNA rule and driver join as `compare`, so a one-gene set equals that gene's own count); each member's % is over the patients profiled for THAT gene. The `topGenes` most-altered members are drawn per pathway; the rest are pooled as "+N more altered", and members on no participating panel are listed as never profiled. Two cohorts add Fisher tests with BH q across pathways, and separately across member genes. Counts only; a remote peer cannot answer yet and is named | | `diffexpr` | `profileType, log2?, geneSets?:[≤12 MSigDB names], label?:12, cohorts:[2 names]` | **differential expression between exactly two cohorts** on one molecular profile (mRNA z-scores, RNA-seq, protein, methylation — refused, listing what the study has, if absent): per gene, Welch's t-test from each cohort's n, Σv and Σv² — additive, so the pooled test is exact across sites and no value is ever sent — with each patient contributing the mean of their samples. `log2` compares log2(v+1) and defaults to true for a non-z-score RNA-seq profile; the effect is the difference of means (first cohort minus second) on the compared scale, a log2 fold change under log2. q is Benjamini–Hochberg across every gene tested; genes with fewer than 3 patients in a cohort or no variance are left out and counted. Drawn as a volcano — the 1,500 most significant genes as points, the rest as density, `label` genes named, split between the two directions. Then **preranked GSEA** (Subramanian 2005, weight 1) of the unrounded t ranking against `geneSets` (default: the 50 MSigDB Hallmark sets; `[]` skips it): NES, a permutation p from 1,000 random same-size sets seeded by the set's name (so a shared link reproduces it), BH q across the sets, the leading edge, and the running sum for the strongest set each way. Gene-set permutation ignores gene–gene correlation (anti-conservative next to phenotype permutation, which would need patient-level values) — the caption says so. A remote peer cannot answer yet and is named | | `paired` | `genes?:[≤20], top?:1-40, pairBy?:{attribute, a, b, studies?:{:{attribute, a, b}}, labels?:{a, b}}, measure?:"driver"|"any", cohorts:[names] (≤3)` | **paired samples within patients**: among patients with at least one sample on each side of `pairBy` (a SAMPLE-level attribute and its values for side a and side b — default `SAMPLE_TYPE` Primary / Metastasis; values fold on case and whitespace like every clinical predicate), per gene, the patients with a driver (or, with `measure:"any"`, any alteration) on both sides, on side a only, on side b only — counted only among patients profiled for that gene on BOTH sides by each side's own panels, so a gene one side never assayed never reads as discordant. A patient with several samples on a side is altered there if any is. Concordance = both ÷ altered anywhere; an exact McNemar test (two-sided binomial on the discordant patients) asks whether discordance runs one way, BH q across the genes of each cohort. When no patient has both sides, the refusal lists the values the attribute actually takes. Sample type records where tissue came from, not when — the caption says "metastasis-only" is not "acquired later". Counts only; a remote peer cannot answer yet and is named. **Without `genes` it scans**: every gene with a driver (or any alteration) in some paired patient is tested, q is Benjamini–Hochberg across all of them, and the `top` (default 20) most one-sided are drawn, ordered by McNemar p — how to ask "what is acquired after treatment" without naming genes first. **`pairBy.studies`** gives each study its own attribute and values (PRE-TX / POST-TX in one, pre-treatment / post-treatment in another) so their patients pool into one count; a study not listed uses the top-level attribute, and `labels` names the two sides in the figure (default: their values). With an attribute other than a sample site, the caption says the sides are as each study labels them, and that a side-only call can also be a subclone one biopsy missed or a lower-purity sample under-calling | +| `acquired` | `anchor?:{eventType, key?, value?}, genes?:[≤30], minPost?:1-1000, top?:1-40, preWindowDays?, minGapDays?:0-3650, sampleEvents?:[types], sampleKey?, measure?:"any"|"driver", cohorts:[names] (≤3)` | **alterations acquired under therapy** (preview): patients with a sample dated on or before their FIRST `anchor` event (default `Treatment`, any; `key`/`value` such as SUBTYPE = Hormone or AGENT = …, folded like every timeline match) and another at least `minGapDays` (default 30) after it. A sample's date is its first timeline event of `sampleEvents` (default Sample acquisition / Sequencing / Specimen) naming it in `sampleKey` (default SAMPLE_ID); the before-side is the last sample on or before the anchor day (within `preWindowDays` when given), the after-side the first past the gap — one sample each. Per gene, counted only where BOTH samples' panels cover it: altered after only (acquired), before only (lost), on both (shared), neither; exact McNemar on acquired vs lost, BH q per cohort. `measure:"driver"` counts only OncoKB oncogenic alterations (pinned). Without `genes` it scans every gene altered after treatment in at least `minPost` (default 3) patients and draws the `top` (default 20) smallest p. The result carries the funnel (anchored → a sample before → after → both) and the pairs' spacing in days. Second biopsies are clinically indicated (often at progression), so acquired means seen after and not before, not caused by the treatment — the caption says so. Counts only; a remote peer cannot answer yet and is named. | +| `ctdna` | `genes?:[≤30], top?:1-30, attribute?, plasma?:[values], tissue?:[values], plasmaPanels?:[panels], withinDays?:0-3650, sampleEvents?, sampleKey?, measure?:"any"|"driver", chFilter?:true|{genes?, normal?, normalMinAlt?:2, normalMinRatio?:0.3}, cohorts:[names] (≤3)` | **ctDNA against tissue** (preview): samples are plasma when the sample attribute `attribute` (default SAMPLE_CLASS) is one of `plasma` (default cfDNA, ctDNA) or the sample is on one of `plasmaPanels` (e.g. ACCESS129), tissue when it is one of `tissue` (default Tumor). Among patients with both, per gene covered by BOTH kinds' panels: altered in both, tissue only, plasma only, neither (several samples of a kind: altered if any is); positive percent agreement = both ÷ altered in tissue with a 95% Wilson interval, and overall concordance (both + neither) ÷ pairs. Without `genes`, the `top` (default 15) most altered genes. ctDNA detected = any alteration (with `measure:"driver"`, any OncoKB oncogenic one) called in a plasma sample, as a share of the plasma patients with a Wilson interval; overall survival (OS_MONTHS/OS_STATUS as the study records them) by detected vs not, Kaplan–Meier with a log-rank test stratified by study, drawn when each group has at least 5 patients. `withinDays` keeps only samples the timeline dates (as the acquired view does) within that many days of a sample of the other kind, and the result says how many were dated. The caption names clonal haematopoiesis behind plasma-only calls and possible immortal time when OS starts before the draw. **`chFilter`** leaves out plasma mutation calls likely from clonal haematopoiesis (Razavi et al. 2019) before anything is counted: calls in `genes` (default DNMT3A, TET2, ASXL1, PPM1D, JAK2, SF3B1, SRSF2, U2AF1, CBL, GNB1, GNAS, IDH2 — TP53, ATM and CHEK2 are left off because they are tumour drivers as often as CH genes; `[]` for none), and (`normal`, default on) calls whose matched white-cell sample carries at least `normalMinAlt` alternate reads at an allele fraction at least `normalMinRatio` × the plasma's, where the study loads normal read counts (tumour DNA that reaches the white-cell sample sits far below that ratio and is kept). Copy-number and structural calls and tissue are never filtered. The result says, per cohort, how many plasma calls and patients each rule removed and in which genes, recomputes agreement, ctDNA detected and survival with the filter, and gives the unfiltered detected share, plasma-only counts and log-rank p beside them. Counts only; a remote peer cannot answer yet. | +| `exclusivity` | `genes:[2-20], stratifyBy?:, measure?:"any"|"driver", cohorts:[one name]` | **mutual exclusivity against each tumour's alteration burden** (preview; DISCOVER, Canisius et al. 2016): over the cohort's samples with a gene panel (every sample of each patient), each sample and gene get a background probability σ(a_sample + b_gene), fitted so that every sample's expected number of altered genes is its observed burden and every gene's expected count its observed one — over the whole altered-gene matrix, each sample's own panels deciding which genes it has a probability for (panel-aware; a gene off its panel is not profiled there). Within each study, and within each value of `stratifyBy` (e.g. CANCER_TYPE) when given — the confounder DISCOVER exists to remove. For each pair, over the samples profiled for both, the co-altered count is tested against the Poisson-binomial with p_i = p_iA·p_iB: lower tail exclusivity, upper tail co-occurrence, each BH across the pairs; exact, or the refined normal approximation where the exact sum is too large (counted in the caption). The plain two-sided Fisher test on the same samples is reported beside it. More than 20 genes is refused with the limit named. Counts only; a remote peer cannot answer yet. | +| `instability` | `threshold?:0.05-2, armShare?:0.25-0.9, includeX?:bool, cohorts:[names] (≤6)` | **genomic instability from copy-number segments** (preview; hg19 only — a study on another build is refused, naming which is which): per sample, inside the database, FGA = the share of its segmented length (autosomes; X with `includeX`) with |segment mean| ≥ `threshold` (default 0.2); each arm (p/q split at hg19's centromeres from UCSC cytoBand, the acrocentric p arms of 13, 14, 15, 21, 22 left out: 39 arms) gained or lost when more than `armShare` (default 0.5) of its length is past ±threshold, measured when segments cover that share; the aneuploidy score is the number of arms gained or lost (Taylor et al. 2018 style). Only histograms (FGA in 1-point bins × score) and per-arm counts leave the database. Per cohort the quartiles and 5th/95th percentiles of both, Mann–Whitney (2 cohorts) or Kruskal–Wallis (3+) from the histograms, and each arm's gain and loss share of the samples measuring it. Every sample of each patient counts; panel segments are noisy and purity is not corrected — the caption says so. Counts only; a remote peer cannot answer yet. | | `clonality` | `genes:[≤12], measure?:"driver"|"any", qc?:true, cohorts:[names] (≤3)` | **clonality from FACETS allele-specific copy number** (only studies that carry it — the refusal names them): per gene, the cohort's mutation CALLS (one mutation in one sample; OncoKB drivers by default) that FACETS called clonal, subclonal or indeterminate, LOH at the locus (minor copy number 0, of calls where it was estimated) and the cancer-cell fraction in ten bins; per cohort, FACETS's own whole-genome-doubling, purity and ploidy calls. `qc` (default true) uses only samples whose `FACETS_QC` is Pass, with the pass/fail counts reported. Only the study's current mutation profile is read (the table also holds rows orphaned by superseded loads). Two cohorts add Fisher tests on clonal vs subclonal per gene (BH across genes) and on WGD. Counts only; a remote peer cannot answer yet and is named | | `incidence` | `event:{eventType, attrs?:[{key,value} ×≤3], label?}, anchor?:{eventType,key?,value?,occurrence?}, horizons?:[12,24,60], cohorts:[names] (≤6)` | **cumulative incidence with a competing risk**: time from the anchor (default the study's day 0) to the FIRST `event` after it, with death (`OS_STATUS`) as the competing event and `OS_MONTHS` as follow-up — Aalen–Johansen, with 95% intervals from its delta-method standard error (checked against R's cmprsk: estimates identical to five decimals, SEs within 3%). Not 1 − Kaplan–Meier, which censors the dead as if still at risk and overstates incidence wherever death is common; the view reports both at each of `horizons`, so the difference is on the page. Left truncation as in `survival`: a patient anchored before day 0 enters the risk set at day 0. Patients who cannot be at risk are counted by reason, never read as event-free: already had the event at or before the anchor, had it before observation began, no follow-up, no anchor event. Two or more cohorts: Gray's test (weight 1) by permutation of cohort labels over the pooled records — exact under the null, seeded, and computable from the (entry, time, cause) tallies each site sends, which are counts | -| `riskmodel` | `cohorts:[one name], endpoint?, monthsAttribute?/statusAttribute?, measure?:"any"|"driver", minMutated?:30, strata?:"", covariates?:["" ×≤6], anchor?:{eventType,key?,value?,occurrence?}, validate?:false, alpha?:0.5, runs?:10 (≤100), groups?:3 (2-5) | cuts?:[quantiles ×1–4], seed?:1, labelAbove?:0.5, describe?:{attributes?:["" ×≤20], genes?:["" ×≤20]}` | **a survival risk model from mutated genes**, in the manner of OncoCast (Martin et al.): an elastic-net Cox model (glmnet's algorithm, checked against scikit-survival; Breslow ties; `alpha` the l1 share of the penalty) of one cohort's survival on every gene that the panels of EVERY patient cover — so no patient counts as wild-type for a gene never tested — and that is mutated in at least `minMutated` patients. Fitted `runs` times, each on a random two-thirds of the patients with the penalty chosen by 3-fold cross-validated partial likelihood, scoring the held-out third; a patient's risk score is the mean of their held-out scores, so no one is scored by a model trained on them (a patient never held out has no score and is counted). `covariates` are clinical attributes always in the model and never penalised (numbers as they are, categories as indicators against the commonest value, at most 10 values); a patient missing one is left out and counted. `anchor` starts each patient's clock at a timeline event, as the survival view does, with the delayed entry that implies (patients without the event, or whose event follows the study's day 0, are counted out). `validate` trains only on the discovery half of the engine's fixed split (the `split` leaf's halves) and reports everything below on the validation half, which no model saw, cut at the discovery half's quantiles. Reports the held-out C-index across runs (and on the validation half), each gene's share of runs kept and mean hazard ratio, the covariates' hazard ratios, Kaplan-Meier curves of `groups` risk groups cut at equal quantiles of the score (no outcome used; a cut moves to the nearer edge of a run of tied scores, so equal scores share a group, and groups merge when the scores cannot fill them) with a log-rank test — or, with `cuts` instead of `groups` (not both), cut at the quantiles it names, increasing, each strictly between 0 and 1, as OncoCast's `cuts` (e.g. `[0.8]`, the top fifth against the rest; tied scores still share a group, and the caveat says the cuts were chosen, since cuts placed by looking at the curves overstate the separation — confirm one on the validation half) — each group's 12- and 36-month survival and hazard ratio against the lowest (95% Wald interval), the hazard ratio per standard deviation of the score, and the score's histogram. The score is shown on OncoCast's 0–10 scale as well as raw: (score − min) / (max − min) × 10, min and max the lowest and highest held-out score the cuts are placed on (with `validate`, the discovery half's, so a validation patient can fall outside 0–10); the scale is linear, so it changes no group. The genes are also drawn as OncoCast's volcano — mean hazard ratio where kept (log scale) against the share of runs kept, sized by patients mutated — naming those kept in at least `labelAbove` of the runs (0–1; drawn in the browser, so changing it re-runs nothing). `strata` gives each value its own baseline hazard — use CANCER_TYPE on a mixed cohort, or a gene marking a type carries that type's prognosis. `describe` adds **Table 1**, each risk group's makeup, counted on the patients the curves draw (with `validate`, the validation half): a categorical attribute's patients per value and group (the patient's value, or their first sample's, case and whitespace folded; those whose samples disagree are counted), with a χ² test; a numeric one's median and IQR per group, with Kruskal–Wallis; a gene's mutated patients over those whose mutation panels cover it (`measure` decides what counts as mutated; patients whose panels do not are "not profiled", never unmutated), with a χ² test; Benjamini–Hochberg q-values across rows. Rows the groups differ in by construction are marked — the model's genes (kept in any run) and covariates "in the model", the endpoint's own columns "outcome" — and get a p-value but no q-value, staying out of the correction; the model's stratum is marked "stratum" and stays in, since the score has no stratum term. A list left out takes its default: `attributes`, the model's stratum and covariates then the study's attributes in priority order, survival columns left out, keeping the first 8 that have a value here, take more than one, and (categorical) at most 12 values — those passed over are listed with why; `genes`, the 15 the model kept in the most runs. A named attribute the study lacks, a categorical one with more than 100 values here, and a gene on none of the patients' panels and mutated in none are set aside by name. `describe: {}` is the defaults. One cohort in one patient-level study; at most 300,000 patients × runs per request (refused, naming the limit). Seeded: the same document gives the same model | +| `cutpoint` | `attribute?:"TMB_NONSYNONYMOUS", endpoint?:"OS", monthsAttribute?/statusAttribute?, permutations?:1000 (100–5000), seed?:1, stratifyBy?:"", minStratum?:100, cohorts:[one name]` | **a biomarker's best survival cut-point, corrected for the search** — **preview** (Samstein 2019, PMID 30643254; PMID 38949888): a numeric marker (the patient's value, or a sample attribute's on the patient's first sample by sample id) against the endpoint. Candidate cuts are the marker's distinct values leaving 10–90% of patients at or below them (at most 100, evenly spaced); at each, the maximally selected standardized log-rank statistic (Lausen & Schumacher; R maxstat's LogRank). The best cut is reported with its permutation-corrected p (`permutations` seeded shuffles of the marker; the share whose own best cut does as well) beside the naive log-rank p, the high-vs-low Cox HR, the statistic-against-cut curve (with the permutation 95% line) and Kaplan–Meier curves at the cut. `stratifyBy`: a scan per value of a categorical attribute (the largest 8 with ≥ `minStratum` patients; the rest counted), as a table. The cut is chosen in-sample and needs validation — the caveat says so. Patient-level studies only | +| `hla` | `resolution?:1|2|"full", alleles?:{A?:[first, second]|[both], B?, C?}, lohAttributes?:[names], lohValues?:[values], endpoint?:"OS", monthsAttribute?/statusAttribute?, covariates?:["" ×≤4], cohorts:[names] (≤3)` | **HLA class I genotype and HLA LOH** (preview; Chowell et al. 2018, McGranahan et al. 2017): per patient, whether each of HLA-A, -B and -C carries two alleles that match at `resolution` fields of the allele name (default 2, protein level: hla_a_02_01_01_01, A*02:01 and A0201 all read A*02:01; 1 = allele group; "full" = every field recorded). The alleles are read from the layouts the public studies use — HLA-A1/HLA-A2 (MSK-IMPACT), HLA_A1_ALLELE(S)/HLA_A2_ALLELES, or one HLA_A attribute holding "A0201/A2402" — or from `alleles`. Per cohort: the share of patients typed at all three loci homozygous at 0, 1, 2 or 3, per-locus homozygosity, and the share of patients with an HLA LOH call (`lohAttributes`, default HLA_A1_LOH … HLA_C2_LOH and HLA_LOH, any sample) that is a loss (`lohValues`, default Loss/Yes/LOH/True). Then the endpoint (default OS) by homozygous at ≥1 locus against heterozygous at all, and by HLA LOH against none: Kaplan–Meier, log-rank and Cox HR (stratified by study when pooled), drawn when each group has ≥ 5 patients; with `covariates`, the HR adjusted for them (numbers as they are, categories against the commonest, past 10 levels pooled). A study without HLA alleles is refused, listing its HLA-named attributes. The endpoint runs from the study's own origin — a checkpoint-blockade start only in a study of treated patients that measures from it — and the caption says homozygosity then reads prognosis, not response. Patient-level studies only. | +| `predictive` | `exposure:{eventType,key?,value?}, genes:[≤20], endpoint?:"OS", monthsAttribute?/statusAttribute?, measure?:"any"|"driver", covariates?:["" ×≤6], minMutated?:5, cohorts:[1–2 names]` | **predictive biomarkers, gene × treatment** — **preview** (PMIDs 29122777, 32913971, 39499893): the cohorts' patients pooled (baseline hazard stratified by cohort and study when several), `exposure` = any timeline event of that type (and key = value), the rest unexposed. Per gene, a Cox model (Efron) of the endpoint on gene + exposed + gene×exposed (+ `covariates`, coded as the `cox` view codes clinical axes), over the patients whose panel covers the gene — a patient not profiled for it is left out of that fit, never wild-type. Reported per gene: the mutated-vs-wild-type HR among the exposed and among the unexposed (the same model's two contrasts: the interaction coded both ways) and the interaction HR with its Wald p and a Benjamini–Hochberg q over the genes fitted; a gene with fewer than `minMutated` mutated patients in either group is listed, not fitted. Drawn as a two-marker forest. Exposure is defined from the whole record (immortal-time bias) and was not randomised (confounding by indication) — the caveat says so. Patient-level studies only | +| `rmst` | `endpoint?:"OS", monthsAttribute?/statusAttribute?, anchor?:{eventType,key?,value?,occurrence?}, tau?:months, landmarkMonths?:[months ×≤4] (default [0]), cohorts:[names] (≤6)` | **restricted mean survival time and landmark analysis** — **preview**, for when hazards are not proportional (immunotherapy): per cohort the area under the Kaplan–Meier curve from 0 to `tau` (the mean months lived — or event-free — within the first τ) with the Greenwood-type standard error of that area (Klein & Moeschberger; R survRM2's rmst1) and a 95% interval; against the first cohort the RMST difference and ratio (log scale), each with a Wald 95% interval and two-sided p. `tau` defaults to the largest time at which every cohort still has 10 at risk (stated); a `tau` past a cohort's last follow-up is refused. `landmarkMonths`: for each landmark L the patients alive and uncensored at L, the clock reset there, KM and RMST from it (τ counted from L), drawn as small multiples. The endpoint is `OS` (or `_MONTHS/_STATUS`, or `monthsAttribute`/`statusAttribute`) from day 0; `anchor` instead measures OS from a treatment start exactly as `txendpoints` anchors (first record, or LAST; OS_MONTHS on the timeline clock, the same clock check; left truncation for an anchor before day 0). KM drawn with the RMST area shaded to τ, and a table. Sites send (entry, time, event) counts only | +| `multistate` | `progression?:{eventType?:"Diagnosis", key?:"PROGRESSION", values?:["Y","Yes"], probabilityKey?, minProbability?}, prevalent?:"include"|"exclude", horizons?:[12,24,60], cutoffMonths?, cohorts:[names] (≤4)` | **a multistate (illness–death) model** (Rueda et al., Nature 2019, PMID 30867590) — **preview**: states start (the timeline's day 0 — first sequencing in MSK-CHORD; the view checks how many patients have a Sequencing event on day 0 and says what day 0 is), progression (the first `progression` event after day 0, as `txendpoints` reads it: values folded, an optional probability floor) and death (OS_STATUS / OS_MONTHS on the timeline's clock, with `txendpoints`' clock check). A patient with a progression on or before day 0 starts in the progression state (`prevalent:"exclude"` leaves them out); a progression on or after the end of follow-up is not counted. Markov (clock-forward): every hazard runs on time since day 0, progression → death with delayed entry at the progression. Per cohort: each transition's events, person-months and rate per 100 person-months (Poisson 95% interval), Nelson–Aalen cumulative hazards, Aalen–Johansen state occupation drawn as stacked areas (to where 10 patients remain followed) and tabled at `horizons`; per transition a Cox model (Efron; delayed entry for progression → death; a baseline per study) of each cohort against the first, no ratio for a cohort with under 5 events. Sites send (prevalent, progression time, end time, dead) counts only. Progression from radiology NLP is probabilistic and unseen in patients never imaged — the caption counts them | +| `txendpoints` | `anchor:{eventType,key?,value?,occurrence?}, endpoint?:"ttnt"|"tot"|"rwpfs", lineGap?:90, lineClasses?:"systemic"|"all"|[classes], progression?:{eventType?:"Diagnosis", key?:"PROGRESSION", values?:["Y","Yes"], probabilityKey?, minProbability?}, ongoingDays?:30, cutoffMonths?, cohorts:[names] (≤6)` | **real-world treatment endpoints** (MSK-CHORD, Nature 2024; GENIE BPC) — **preview**: the clock starts at the anchored treatment record's start (first, or `occurrence:"LAST"`), and runs to `ttnt` the start of the next treatment LINE after the anchor's (lines as the `sequence` view builds them — `lineGap`, `lineClasses`), `tot` the latest stop of the anchored records in that line (a living patient stopping within `ongoingDays` of last follow-up is censored as still on it; a record without a stop date is left out and counted), or `rwpfs` the first `progression` event after it (values folded; an optional probability floor) — or death, an event for every endpoint; censored at last follow-up, `OS_MONTHS` × 30.4375 days on the timeline clock (checked per study: a study whose timeline runs more than 31 days past OS_MONTHS for over 5% of anchored patients is refused). Left truncation as in `survival`. Kaplan–Meier per cohort with the number at risk, medians with 95% intervals (Greenwood, log scale), and a k-group log-rank (stratified by study). Sites send (entry, time, event) counts only. Treated status is not randomised: the caption says so | +| `riskmodel` | `cohorts:[one name], endpoint?, monthsAttribute?/statusAttribute?, measure?:"any"|"driver", minMutated?:30, strata?:"", covariates?:["" ×≤6], anchor?:{eventType,key?,value?,occurrence?}, validate?:false, alpha?:0.5, runs?:10 (≤100), groups?:3 (2-5) | cuts?:[quantiles ×1–4], seed?:1, labelAbove?:0.5, describe?:{attributes?:["" ×≤20], genes?:["" ×≤20]}, validateOn?:""` | **a survival risk model from mutated genes**, in the manner of OncoCast (Martin et al.): an elastic-net Cox model (glmnet's algorithm, checked against scikit-survival; Breslow ties; `alpha` the l1 share of the penalty) of one cohort's survival on every gene that the panels of EVERY patient cover — so no patient counts as wild-type for a gene never tested — and that is mutated in at least `minMutated` patients. Fitted `runs` times, each on a random two-thirds of the patients with the penalty chosen by 3-fold cross-validated partial likelihood, scoring the held-out third; a patient's risk score is the mean of their held-out scores, so no one is scored by a model trained on them (a patient never held out has no score and is counted). `covariates` are clinical attributes always in the model and never penalised (numbers as they are, categories as indicators against the commonest value, at most 10 values); a patient missing one is left out and counted. `anchor` starts each patient's clock at a timeline event, as the survival view does, with the delayed entry that implies (patients without the event, or whose event follows the study's day 0, are counted out). `validate` trains only on the discovery half of the engine's fixed split (the `split` leaf's halves) and reports everything below on the validation half, which no model saw, cut at the discovery half's quantiles. Reports the held-out C-index across runs (and on the validation half), each gene's share of runs kept and mean hazard ratio, the covariates' hazard ratios, Kaplan-Meier curves of `groups` risk groups cut at equal quantiles of the score (no outcome used; a cut moves to the nearer edge of a run of tied scores, so equal scores share a group, and groups merge when the scores cannot fill them) with a log-rank test — or, with `cuts` instead of `groups` (not both), cut at the quantiles it names, increasing, each strictly between 0 and 1, as OncoCast's `cuts` (e.g. `[0.8]`, the top fifth against the rest; tied scores still share a group, and the caveat says the cuts were chosen, since cuts placed by looking at the curves overstate the separation — confirm one on the validation half) — each group's 12- and 36-month survival and hazard ratio against the lowest (95% Wald interval), the hazard ratio per standard deviation of the score, and the score's histogram. The score is shown on OncoCast's 0–10 scale as well as raw: (score − min) / (max − min) × 10, min and max the lowest and highest held-out score the cuts are placed on (with `validate`, the discovery half's, so a validation patient can fall outside 0–10); the scale is linear, so it changes no group. The genes are also drawn as OncoCast's volcano — mean hazard ratio where kept (log scale) against the share of runs kept, sized by patients mutated — naming those kept in at least `labelAbove` of the runs (0–1; drawn in the browser, so changing it re-runs nothing). `strata` gives each value its own baseline hazard — use CANCER_TYPE on a mixed cohort, or a gene marking a type carries that type's prognosis. `describe` adds **Table 1**, each risk group's makeup, counted on the patients the curves draw (with `validate`, the validation half): a categorical attribute's patients per value and group (the patient's value, or their first sample's, case and whitespace folded; those whose samples disagree are counted), with a χ² test; a numeric one's median and IQR per group, with Kruskal–Wallis; a gene's mutated patients over those whose mutation panels cover it (`measure` decides what counts as mutated; patients whose panels do not are "not profiled", never unmutated), with a χ² test; Benjamini–Hochberg q-values across rows. Rows the groups differ in by construction are marked — the model's genes (kept in any run) and covariates "in the model", the endpoint's own columns "outcome" — and get a p-value but no q-value, staying out of the correction; the model's stratum is marked "stratum" and stays in, since the score has no stratum term. A list left out takes its default: `attributes`, the model's stratum and covariates then the study's attributes in priority order, survival columns left out, keeping the first 8 that have a value here, take more than one, and (categorical) at most 12 values — those passed over are listed with why; `genes`, the 15 the model kept in the most runs. A named attribute the study lacks, a categorical one with more than 100 values here, and a gene on none of the patients' panels and mutated in none are set aside by name. `describe: {}` is the defaults. One cohort in one patient-level study; at most 300,000 patients × runs per request (refused, naming the limit). Seeded: the same document gives the same model. `validateOn` (preview; PMIDs 37477937, 38488807): external validation on another cohort of the document (one patient-level study, any): its patients are scored with the ensemble's mean coefficients (each gene's coefficient averaged over every run, zero where a run dropped it), cut at the training risk-group thresholds, and reported with Harrell's C-index (within strata; seeded bootstrap 95% interval, 200 resamples), Kaplan–Meier curves by group, the log-rank test and each group's HR against the lowest — drawn below the model's figure. A validation patient whose panels miss a gene the model weighs is left out and counted, never scored as wild-type; a model with `covariates` is refused (they are not carried yet). The fit itself is unchanged, and the result without `validateOn` is identical | | `labs` | `test, anchor:{eventType,key?,value?,occurrence?}, eventType?:"Lab_Test", testKey?:"TEST", resultKey?:"RESULT", baselineDays?:90, fromWeeks?:-12, toWeeks?:52, binWeeks?:4, responseDrop?:50, cohorts:[names] (≤4)` | **a lab value's course from a treatment start**: each patient's `test` results (timeline events of `eventType` whose `testKey` names the test, value in `resultKey`) as % change from their own baseline — the last value in the `baselineDays` up to and including the anchor; a non-positive baseline is left out — per `binWeeks` bin from `fromWeeks` to `toWeeks`, drawn as median and interquartile band (from 2-point histograms, exact to within a point; a patient counts once per bin, at their mean; the baseline reading itself is not drawn). Best response is each patient's lowest value after the anchor; the share falling by at least `responseDrop`% is counted exactly (50 on PSA is PCWG3's PSA50), and two cohorts get a Fisher test on it. A **waterfall** of every patient's best change is drawn only from participants entitled to patient-level values (a method only PatientLevelResolver has, like a plot's points); an aggregate-only participant's patients stay in the histograms and the rate and are counted as withheld from the bars. With no evaluable patient, the refusal lists the tests the study records | | `cancertypes` | `genes:[≤30], groupBy?:"CANCER_TYPE"||"study", measure?:"any"|"driver", minSamples?:10, show?:"frequency"|"count", unit?:"patient"|"sample", cohorts:[names] (≤2)` | **Cancer Types Summary**, cBioPortal's results-view tab: per group of the cohort's PATIENTS (the portal's default; `unit:"sample"` counts samples — a patient counts in each group one of their samples is in) — a sample attribute's value (folded on case and whitespace like every clinical predicate; samples with no value form their own group) or the study — the share altered in at least one of `genes`, split by kind: only mutations, only amplifications, only deep deletions, only structural variants, or more than one kind. A group's denominator is its samples profiled for at least one of the genes (the union of their panels, as an oncoprint's merged row counts it); its total sample count is shown beside it. `measure:"driver"` counts OncoKB drivers only. Groups with fewer than `minSamples` profiled samples are pooled into one bar, named and counted, never dropped. Counts only, so every local participant can answer; a remote peer cannot yet and is named | | `overlap` | `cohorts:[names] (2–6)` | **how cohorts' patients intersect**, cBioPortal's group-comparison Overlap tab: for every combination of the cohorts, the patients — and their samples — in exactly those cohorts and none of the others, with each cohort's own size. Two or three cohorts are drawn as the portal draws them, area-proportional diagrams of samples and of patients side by side; more as an UpSet plot. A cohort is a set of patients, so a patient's samples go wherever the patient does. Each study's database answers with one count per combination (a bitmask of membership) — counts only — and a cohort's size here must equal its own count, or the view says the definition gave two answers. Ids are study-qualified, so patients only ever overlap within a study | | `enrichment` | `alteration?:"mutation"|"cna"|"amp"|"homdel"|"sv", measure?:"any"|"driver", minAltered?:5, label?:12, cohorts:[2–6 names]` | **genomic-alteration enrichment across every gene**, cBioPortal's group-comparison Genomic Alterations tab: each gene's frequency of one alteration type per cohort, counted in SAMPLES over the samples profiled for that gene — samples on a panel listing it (counted per panel, then joined to its genes, Explore's own construction) plus whole-exome samples, and a sample altered off-panel counts as measured. Uncalled mutations are excluded as the portal excludes them; copy number is high-level only. Two cohorts: a two-sided Fisher test and the log2 ratio of the altered shares, drawn as a volcano beside the table; three or more: a chi-squared test. q is Benjamini–Hochberg across every gene tested; genes altered in fewer than `minAltered` samples, or unprofiled in a cohort, are counted as not tested. The caption warns that hypermutated tumours enrich almost every gene. Counts only | +| `actionability` | `groupBy?:"", top?:8, pairBy?:{attribute, a:[values], b:[values]}|null (default SAMPLE_TYPE Primary / Metastasis), cohorts:[names] (≤4)` | **clinical actionability from the version-pinned OncoKB store**: each patient with a sample sequenced for mutations, at their highest sensitive therapeutic level (Level 1, 2, 3A, 3B, 4, or none) over the called mutations and high-level copy-number calls in any of their samples (structural variants too where the store levels them; the caveat says which types it holds), and separately whether any alteration carries a resistance level (R1, R2). The store's level is its highest for the alteration in any cancer type, not in the patient's own, so the shares are an upper bound — stated in the caveat. Per cohort, and with `groupBy` per group of that attribute per patient ("Multiple" when samples disagree; the `top` largest drawn, the rest pooled into a named Other). For patients with a sequenced sample on each side of `pairBy` (values matched case-insensitively), the 6×6 cross-table of their highest level on each side and the discordant share. "No level" means none on the genes sequenced — an unprofiled gene is unknown. Counts only, computed in each local database. | +| `drivernegative` | `drivers?:["" ×≤40] (default EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1, NTRK2, NTRK3), alteration?:"mutation"|"sv"|"cna", measure?:"any"|"driver", minAltered?:5, label?:12, cohorts:[1 name]` | **driver-negative analysis within one cohort**: each patient sequenced for mutations is driver-positive with an OncoKB driver (oncogenic or likely oncogenic, at the pin: a called mutation, a high-level copy-number call or a structural variant) in one of `drivers`; else driver-negative when a mutation panel of theirs covers every listed gene (or whole-exome); else "not fully profiled" — counted, in neither group, never driver-negative. Then every other gene's `alteration` (default mutation; `sv` counts either fusion partner) frequency is compared between the two groups in PATIENTS over the patients profiled for that gene (panel-aware as `enrichment` counts, an off-panel call counting as measured): two-sided Fisher, Benjamini–Hochberg q, log2 of the driver-negative share over the driver-positive one. Drawn as the group split and a volcano, with the genes most enriched in the driver-negative group. Counts only. | +| `organotropism` | `source?:"auto"|"flags"|"timeline"|"sample", flagPrefix?:"DMETS_DX_", timeline?:{eventType?:"Diagnosis", key?:"TUMOR_SITE"}, siteAttribute?:"METASTATIC_SITE", sites?:[labels], genes?:[≤20] | top?:12, alteration?:"mutation"|"cna"|"amp"|"homdel"|"sv", measure?:"driver"|"any", stratifyBy?:"CANCER_TYPE"|null, minPatients?:5, incidence?:{site?, gene?}|null, cohorts:[1 name]` | **metastatic organotropism**: which genes' alterations go with metastasis to which organ (MSK-MET, Nguyen et al. Cell 2022). Sites come from per-organ Yes/No flags (`flagPrefix`), from the timeline (event type and key naming a site, at any date) or from the biopsy site of metastasis samples (free text mapped to organs by the view's disclosed rules, the rest as a listed "Other"); `auto` takes the first the cohort has and the view refuses, naming what exists, when none is. Per site and gene, the patients evaluable for the site and profiled for the gene (panel-aware: an unprofiled patient is in neither group) form a 2×2 per stratum of `stratifyBy` (per patient); Cochran–Mantel–Haenszel test and Mantel–Haenszel odds ratio, Benjamini–Hochberg over the grid; a cell needs `minPatients` altered, with and without the site. Genes: a list, or the `top` most altered. Drawn as a gene × site heatmap of log2 OR (outlined at q < 0.05) and the strongest cells. With the timeline source, also the cumulative incidence of the first event at one site after day 0 by one gene's status (default the grid's most significant cell), death competing (Aalen–Johansen, Gray's permutation test); patients with the site by day 0 are excluded, and the panel is withheld when more than 5% of records have the site after their last follow-up. A site is only what imaging, biopsy or abstraction recorded. Counts only. | +| `molsubtype` | `classifier?:"endometrial", msi?:{attribute?, high?:[values], stable?:[values]} | {attribute, threshold, stableBelow?} (default MSI_TYPE Instable / Stable, else MSI_SCORE or MSI_SENSOR_SCORE ≥ 10 / < 3), p53?:"driver"|"nonsynonymous", by?:"", top?:8, survival?:true, cohorts:[≤6 names]` | **a published molecular classifier as derived subtypes**, so far the endometrial carcinoma classification (TCGA 2013 / ProMisE / WHO 2020): per patient over all their samples, first match wins — POLE-ultramutated (a somatic POLE exonuclease-domain hotspot: P286R, S297F, F367S/V, V411L, L424I/V, P436R, M444K, A456P, S459F, M295R, D368Y; León-Castillo et al. 2020), else MMR-deficient (MSI-high by the `msi` rule), else p53-abnormal (an OncoKB-driver TP53 mutation, or with `p53:"nonsynonymous"` any protein-changing one), else NSMP. A patient missing an input the hierarchy needs before reaching their subtype — not sequenced, not profiled for POLE or TP53 (panel-aware), no MSI-high or stable call — is "not classifiable", counted and kept out of shares, tests and curves; never guessed. Per cohort: subtype shares (a stacked bar; chi-squared between cohorts), with `by` one bar per group (the `top` largest, the rest pooled into a named Other; chi-squared), and overall survival by subtype (Kaplan–Meier, k-group log-rank) for the first three cohorts. Another `classifier` is refused, naming those that exist. Counts only. | +| `germline` | `genes?:[≤60] (default every gene with ≥ minCarriers carriers), minCarriers?:3, survival?:{gene}, cohorts:[≤4 names]` | **germline carriers and their somatic second hits**, per patient: carriers are patients with a mutation the study marks GERMLINE in the gene, among the patients sequenced for it (a tumour mutation panel listing it, or whole-exome; a carrier whose panel omits the gene is counted apart — a gene not on a patient's panel never makes them a non-carrier). The carrier share is of patients sequenced, not germline-tested, since the warehouse cannot tell a negative germline test from none. Among carriers, a somatic second hit in the same gene: another called mutation not marked germline, or a deep deletion (−2, among patients profiled for copy number); loss of heterozygosity is not assessed (no allele-specific copy number is read; the result says when the study has FACETS), so the rate is a floor. Compared with the same somatic rate in non-carriers: Fisher per gene, Benjamini–Hochberg over the cohort's genes. With `survival`, overall survival of that gene's carriers against non-carriers (Kaplan–Meier, log-rank). Counts only. | | `assays` | `profileType:"", minN?:3, label?:12, cohorts:[2–6 names]` | **every entity of one generic-assay profile compared between cohorts**, cBioPortal's group-comparison tabs for generic assays (mutational signatures, arm-level copy number, treatment response, …): one value per sample (every sample of each cohort's patients) or per patient for a patient-level profile. A numeric profile: each entity's n, Σv and Σv² per cohort — values that are not numbers, such as a limit `">8"` or NA, are counted and left out — then Welch's t for two cohorts (drawn as a volcano of the difference in means) or Welch's ANOVA for more. A categorical one: each entity's count per value per cohort — NA and blank are not called and are counted apart — then a chi-squared test on cohort × value. q is Benjamini–Hochberg across every entity tested; an entity with fewer than `minN` values in a cohort, or with no spread, is counted as not tested. Per-probe methylation is refused (hundreds of thousands of probes), as is any profile past 20,000 entities. Counts and moments only | | `composition` | `profileType:"", entities?:[ids] | attributes:["", …2–16], unit?:"patient"|"sample", normalize?:true, sort?:"", cohorts:[1–8 names]` | **what each case is made of, as parts of a whole, compared between cohorts** — the stacked-bar panels of single-cell studies (cell types, cell states) and of signature studies. The parts are a generic-assay profile's entities (per sample, or per patient for a patient-level profile; at most 16 — choose them with `entities`) or 2–16 numeric clinical attributes, `custom:` uploads included (per patient unless `unit:"sample"`). A case with every part, none below zero and a sum above zero is kept, its parts divided by their sum unless `normalize:false`; the rest are counted as left out. Inside the database, per cohort: n, Σp and Σp² per part and Σ, Σ² of each case's Shannon entropy. Each cohort against the view's other cohorts pooled, part by part: log2 of the ratio of means (each + 0.001) and Welch's t, Benjamini–Hochberg across the comparisons (with two cohorts, once per part). The figure draws each cohort's cases as stacked bars ordered by `sort` (default the first part), beside the cohort's mean, and a cohort × part matrix; the bars come only from participants that may show cases, and past 2,000 cases a cohort is drawn as its mean alone, saying so. Sums only, apart from those bars, which carry no ids | | `mutations` | `gene:"", columns?, search?, filters?, sort?:{column, desc?}, rows?:25, cohorts:[≤6 names]` | **one gene's mutation table**, the table under cBioPortal's Mutations-tab lollipop: every called mutation of `gene` in each cohort's samples (uncalled calls left out), one row per call — sample, detailed cancer type, protein change, the OncoKB annotation at the pinned version, mutation and variant type, allele frequency (variant / variant + reference reads) with both read counts, the study's MUTATION_COUNT, chromosome, positions, alleles, mutation status. Column ids: `sample`, `cancerType`, `proteinChange`, `annotation`, `mutationType`, `variantType`, `vaf`, `altCount`, `refCount`, `mutCount`, `chr`, `start`, `end`, `ref`, `var`, `status`; shown by default: sample, cancerType, proteinChange, annotation, mutationType, mutCount (the portal's own). Searched, filtered and sorted as every table view is (see *Table views* below; default order drivers first, then by codon; `proteinChange` sorts by codon, `annotation` drivers first). Its rows name samples, so only a patient-level participant contributes them; any other study is named | @@ -3159,7 +3176,305 @@ MSI_TYPE = Instable (vs every other observed value: Stable, Indeterminate, Do no } ``` -### Example 39: splitBy: one cohort, auto-split into an arm per receptor subtype +### Example 39: Pan-cancer forest: TP53 mutation and survival in 30 cancer types + +TP53 mutation against overall survival across MSK-IMPACT 50K. TP53-mutant patients die sooner in almost every cancer type — prostate (HR 2.5), endometrial (2.9), thyroid (4.1) — but not in glioma (0.86) or melanoma (0.78), where TP53 marks better-prognosis subtypes. Pooled, the random-effects HR is 1.63 (1.42–1.87), but I² is 93%: the cancer types disagree far beyond chance, so the pooled number is an average over different effects, not one effect. + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "forest", + "cohorts": [ + "all patients" + ], + "exposure": { + "source": "alteration", + "gene": "TP53" + }, + "strata": "CANCER_TYPE" + } + ] +} +``` + +### Example 40: Adjusted enrichment: early-onset colorectal cancer, adjusted for MSI and TMB + +Colorectal cancers in MSK-IMPACT 50K diagnosed before 50 against at 50 or later. Early-onset tumours carry BRAF less often (7.4% against 12.9%; adjusted OR 0.52) and RNF43 less often (0.66), and TP53 (77% against 72%; 1.31) and TCF7L2 (1.40) more often — 4 of 56 genes at q < 0.05 once MSI status and TMB are adjusted for, as in Cercek et al. (JNCI 2021). + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "Early-onset CRC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Colorectal Cancer" + ], + "per": "sample" + }, + { + "kind": "clinical", + "attribute": "AGE_AT_DX", + "op": "<", + "value": 50 + } + ] + }, + "Average-onset CRC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Colorectal Cancer" + ], + "per": "sample" + }, + { + "kind": "clinical", + "attribute": "AGE_AT_DX", + "op": ">=", + "value": 50 + } + ] + } + }, + "views": [ + { + "kind": "adjusted", + "cohorts": [ + "Early-onset CRC", + "Average-onset CRC" + ], + "covariates": [ + "MSI_TYPE", + "TMB_SCORE" + ] + } + ] +} +``` + +### Example 41: Clinical actionability: OncoKB levels across 50,000 tumours, and primary against metastasis + +MSK-IMPACT 50K by cancer type. Because a level is the store's best for the alteration in any cancer type, KRAS-mutant pancreatic cancers count at level 1 (87%) and as R1: an upper bound, not a treatment rate. Of the 2,278 patients with a primary and a metastasis sequenced, 25.5% have a different highest level in the two. + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "actionability", + "cohorts": [ + "all patients" + ], + "groupBy": "CANCER_TYPE", + "top": 8 + } + ] +} +``` + +### Example 42: Driver-negative lung cancer: what is mutated when no canonical driver is + +Non-small cell lung cancer in MSK-CHORD. 2,898 of 7,809 patients (37.1%) carry none of the eleven drivers. Their tumours carry NF1 (12.4% against 5.0%), NFE2L2, KEAP1, RASA1, MAP2K1 and NRAS mutations more often — other routes into the same MAPK and oxidative-stress pathways — while RBM10 goes with the drivers. + +```json +{ + "study": "msk_chord_2024", + "cohorts": { + "NSCLC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "drivernegative", + "cohorts": [ + "NSCLC" + ] + } + ] +} +``` + +### Example 43: Metastatic organotropism: which mutations go with spread to which organ (MSK-MET) + +25,775 patients in MSK-MET (Nguyen et al., Cell 2022). Within cancer types, TP53 goes with metastasis to nearly every organ (liver odds ratio 1.45), EGFR with the brain (1.63), bone and peripheral nerves, APC with liver and lung, and PTEN and ARID1A with less peritoneal and liver spread — the paper's organotropism map from counts alone. + +```json +{ + "study": "msk_met_2021", + "cohorts": { + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "organotropism", + "cohorts": [ + "all patients" + ] + } + ] +} +``` + +### Example 44: Brain metastasis in lung cancer by EGFR, from dated tumour sites + +Non-small cell lung cancer in MSK-CHORD. EGFR-mutant tumours carry brain sites more often (OR 1.7) and gain them faster after sequencing: 32% by five years against 23%, Gray's p < 0.001; STK11, KEAP1 and SMARCA4 go with adrenal spread. + +```json +{ + "study": "msk_chord_2024", + "cohorts": { + "NSCLC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "organotropism", + "cohorts": [ + "NSCLC" + ], + "source": "timeline", + "genes": [ + "EGFR", + "KRAS", + "TP53", + "STK11", + "KEAP1", + "SMARCA4" + ], + "incidence": { + "site": "CNS/Brain", + "gene": "EGFR" + } + } + ] +} +``` + +### Example 45: Endometrial cancer molecular subtypes by genetic ancestry (POLE, MMRd, p53abn, NSMP) + +2,373 endometrial cancers in MSK-IMPACT 50K; 196 have no MSI call. p53-abnormal tumours are 71% of classified patients of African ancestry against 34% of non-Ashkenazi European (as in Weigelt et al. on Black and White patients, PMID 37651310), and survival separates as published: POLE-ultramutated best, p53-abnormal worst (median 29.6 months). + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "Endometrial": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Endometrial Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "molsubtype", + "cohorts": [ + "Endometrial" + ], + "by": "ANCESTRY_LABEL" + } + ] +} +``` + +### Example 46: The endometrial classifier against TCGA's own molecular subtypes + +Of TCGA's 49 POLE tumours 48 are called POLE-ultramutated, 122 of 126 MSI tumours MMR-deficient, 139 of 161 copy-number-high p53-abnormal, and 135 of 145 copy-number-low NSMP. + +```json +{ + "study": "ucec_tcga_pan_can_atlas_2018", + "cohorts": { + "UCEC": { + "all": [] + } + }, + "views": [ + { + "kind": "molsubtype", + "cohorts": [ + "UCEC" + ], + "by": "SUBTYPE" + } + ] +} +``` + +### Example 47: Germline carriers in metastatic prostate cancer, and their tumours' second hits + +SU2C/PCF metastatic castration-resistant prostate cancer (Abida et al. 2019), one of the few public studies that deposit germline calls. 17 of 429 patients (4.0%) carry a germline BRCA2 variant, 3 of them with a second somatic BRCA2 mutation against 7.3% of non-carriers; 2 of 6 BRCA1 carriers against 0.5% (q 0.005). Loss of heterozygosity is not assessed, so these are floors. Only 128 patients have OS recorded. + +```json +{ + "study": "prad_su2c_2019", + "cohorts": { + "mCRPC": { + "all": [] + } + }, + "views": [ + { + "kind": "germline", + "cohorts": [ + "mCRPC" + ], + "minCarriers": 2, + "survival": { + "gene": "BRCA2" + } + } + ] +} +``` + +### Example 48: splitBy: one cohort, auto-split into an arm per receptor subtype "all patients" split by RECEPTOR_STATUS_PRIMARY gives the same 4 clinically standard subtypes a hand-written 4-cohort document would (HR+/HER2-, Triple Negative, HR+/HER2+, HR-/HER2+), plus a few small ones this study also records (HR+/HER2_Unknown, Unk/ND, HR+/HER2_Equivocal) — all within the default 8-arm cap, so nothing here needs pooling into "Other". TP53 reads 26.8%/90.4%/49.2%/77% across the four main subtypes, matching the published literature on triple-negative breast cancer's markedly higher TP53 mutation rate. @@ -3187,7 +3502,7 @@ MSI_TYPE = Instable (vs every other observed value: Stable, Indeterminate, Do no } ``` -### Example 40: splitBy: gene mutation status, without hand-writing the mutant/wildtype pair +### Example 49: splitBy: gene mutation status, without hand-writing the mutant/wildtype pair "all patients" split by APC mutation status gives the same two arms a hand-written {"kind":"oql","q":"APC: MUT"} cohort pair would (325 mutant, 146 wildtype) — APC-mutant patients have better overall survival (median 25.6mo vs 17.9mo, log-rank p=9.1e-5), matching this study's own headline finding (see the crc_apc_impact_2020 Cox example above) from a single view, no separate cohorts to define. @@ -3211,7 +3526,7 @@ MSI_TYPE = Instable (vs every other observed value: Stable, Indeterminate, Do no } ``` -### Example 41: splitBy: a list of OQL queries, arms that overlap and a disclosed "Other" +### Example 50: splitBy: a list of OQL queries, arms that overlap and a disclosed "Other" "all patients" split by ["TP53: MUT", "KRAS: MUT"] gives 13,105 TP53-mutant, 7,115 KRAS-mutant, and 9,019 in neither ("Other") — summing to more than the 24,950-patient base cohort because 4,289 patients carry both mutations, which the result discloses as `splitBy.overlapCount` rather than leaving the arm counts to look inconsistent on their own (13,105 + 7,115 − 4,289 + 9,019 = 24,950, exactly the base count). @@ -3242,7 +3557,7 @@ MSI_TYPE = Instable (vs every other observed value: Stable, Indeterminate, Do no } ``` -### Example 42: Survival split three ways: the k-group log-rank +### Example 51: Survival split three ways: the k-group log-rank MSI status in three groups (stable / instable / indeterminate) instead of the usual two-arm split. The test asks whether the three curves differ AT ALL, not which pair does — a different question from running the 2-arm test three times pairwise, which would need its own multiple-testing correction. @@ -3301,7 +3616,7 @@ MSI status in three groups (stable / instable / indeterminate) instead of the us } ``` -### Example 43: A missing alteration type is disclosed, never a false negative +### Example 52: A missing alteration type is disclosed, never a false negative TP53 (a real mutation hotspot here) and MDM2 (never assayed for copy-number in this study) side by side. Both show 0%-or-real altered figures, but only the view's note — and each cell's per-type breakdown (mutation/CNA/structural-variant counts, visible on hover in the app) — reveals that MDM2's 0% means "never checked" while TP53's is a real, profiled result. @@ -3337,7 +3652,7 @@ TP53 (a real mutation hotspot here) and MDM2 (never assayed for copy-number in t } ``` -### Example 44: Four receptor subtypes, one real test: Welch's ANOVA +### Example 53: Four receptor subtypes, one real test: Welch's ANOVA Breast cancer's four receptor subtypes, compared on genomic instability (FRACTION_GENOME_ALTERED) and mutation burden (TMB_NONSYNONYMOUS). Triple-negative's markedly higher genomic instability is exactly the known biology, and both the parametric (ANOVA) and rank-based (Kruskal-Wallis) tests agree emphatically; TMB does not differ significantly by subtype here, and again both tests agree. @@ -3412,7 +3727,7 @@ Breast cancer's four receptor subtypes, compared on genomic instability (FRACTIO } ``` -### Example 45: A non-standard survival endpoint, named directly +### Example 54: A non-standard survival endpoint, named directly MSI-H vs MSS endometrial cancer, progression-free survival from first-line treatment — the study's actual recorded outcome, reachable only because the exact column names are given rather than assumed from an OS/PFS/DFS-style prefix. @@ -3460,7 +3775,7 @@ MSI-H vs MSS endometrial cancer, progression-free survival from first-line treat } ``` -### Example 46: Top 20% TMB, within one cancer type: a percentile cutoff +### Example 55: Top 20% TMB, within one cancer type: a percentile cutoff tmb_mskcc_2018 is pan-cancer, so a plain percentile would mix histologies with very different baseline TMB. "NSCLC high-TMB"/"NSCLC low-TMB" both restrict to NSCLC first, then split on whether TMB is at or above the 80th percentile COMPUTED WITHIN THAT NSCLC POPULATION (percentileOf:"NSCLC") — the readback shows the resolved number and what it was a percentile of, not just a bare threshold. @@ -3533,7 +3848,7 @@ tmb_mskcc_2018 is pan-cancer, so a plain percentile would mix histologies with v } ``` -### Example 47: Cohort attrition: which criterion removed whom +### Example 56: Cohort attrition: which criterion removed whom KRAS G12C lung adenocarcinoma treated with immunotherapy, with at least six months of follow-up, beside KRAS wild-type lung adenocarcinoma. The G12C arm loses most of its patients to the genotype, and a further 78 to the follow-up requirement — the kind of step a reader needs to see before trusting a survival curve drawn from what is left. @@ -3610,7 +3925,7 @@ KRAS G12C lung adenocarcinoma treated with immunotherapy, with at least six mont } ``` -### Example 48: Genome-wide copy number: colorectal against breast +### Example 57: Genome-wide copy number: colorectal against breast Colorectal and breast cancers in MSK-IMPACT, 2 Mb bins. The textbook colorectal pattern is all there — 20q gained in 60%, 13q in 42%, 8q in 32%, 18q lost in 54% — beside breast cancer's 1q and 8q gains and 16q loss. The strip underneath marks every bin where the two differ at q < 0.05, coloured by which cohort is higher. @@ -3656,7 +3971,7 @@ Colorectal and breast cancers in MSK-IMPACT, 2 Mb bins. The textbook colorectal } ``` -### Example 49: Oncogenic signalling pathways: colorectal against pancreatic +### Example 58: Oncogenic signalling pathways: colorectal against pancreatic Colorectal and pancreatic cancer in MSK-CHORD. WNT separates them almost completely (82% against 8%, nearly all APC); RTK-RAS is higher in pancreatic cancer (84%, KRAS 81%), and so is the cell cycle (39%, CDKN2A 33%); PI3K and NOTCH (FBXW7) are colorectal. The canonical pathways also show how much a targeted panel misses: 56 of WNT's 68 genes are on no panel here. @@ -3701,7 +4016,7 @@ Colorectal and pancreatic cancer in MSK-CHORD. WNT separates them almost complet } ``` -### Example 50: Differential expression and enrichment: basal-like against luminal A breast cancer +### Example 59: Differential expression and enrichment: basal-like against luminal A breast cancer TCGA PanCancer Atlas breast cancer, PAM50 basal-like against luminal A, on mRNA z-scores. The luminal genes (ESR1, PGR, FOXA1, GATA3) and the basal ones (FOXC1, and proliferation — MKI67, CDC20, AURKB) come out as expected, and so does the enrichment: E2F targets, the G2M checkpoint and MYC targets in basal-like tumours, early estrogen response in luminal A. @@ -3745,7 +4060,7 @@ TCGA PanCancer Atlas breast cancer, PAM50 basal-like against luminal A, on mRNA } ``` -### Example 51: Paired samples: what differs between a patient's primary and metastasis +### Example 60: Paired samples: what differs between a patient's primary and metastasis MSK-IMPACT, the 2,278 patients sequenced at both a primary and a metastatic site. Truncal drivers agree (TP53 78% concordant); what the metastasis alone carries is the resistance biology — ESR1 mutations (49 metastasis-only against 7 primary-only) and AR alterations (71 against 1), both selected under hormone therapy — and MYC amplification and CDKN2A loss. @@ -3778,7 +4093,7 @@ MSK-IMPACT, the 2,278 patients sequenced at both a primary and a metastatic site } ``` -### Example 52: Paired samples, every gene: what the metastasis carries that the primary does not +### Example 61: Paired samples, every gene: what the metastasis carries that the primary does not MSK-IMPACT again, the 2,278 patients with both kinds of sample, but no gene list. Of 347 genes with a discordant patient, the scan puts AR (71 metastasis-only against 1 primary-only) and ESR1 (49 against 7) first — the hormone-therapy resistance genes the hand-picked version above had to be told to look at — followed by MYC, NKX3-1 and PTEN. @@ -3802,7 +4117,7 @@ MSK-IMPACT again, the 2,278 patients with both kinds of sample, but no gene list } ``` -### Example 53: Before and after treatment, pooled across studies that label it differently +### Example 62: Before and after treatment, pooled across studies that label it differently 111 patients with a sample on each side across gastroesophageal (two studies), breast (alpelisib, and a second cohort) and bladder cancer. At this size nothing survives correction across the 278 genes tested — the honest answer — and the most one-sided genes run the other way: CCND1 and MYC amplifications seen before treatment and not after (10 and 10 patients against 1 and 2), as a lower-purity post-treatment sample would also show. Read it as a lead, not a finding; the same view over a large serially sampled cohort is where acquired resistance would show. @@ -3866,30 +4181,220 @@ MSK-IMPACT again, the 2,278 patients with both kinds of sample, but no gene list } ``` -### Example 54: Clonality, LOH and genome doubling from FACETS +### Example 63: Acquired under therapy: the sample before a treatment against the sample after it -Colorectal against breast cancer. Tumour suppressors show the classic second hit — TP53 is ~88% clonal with its other allele lost 77–89% of the time, CDH1 in breast 95% — while the oncogene KRAS rarely loses its partner. PIK3CA is markedly more often subclonal in colorectal cancer (68% clonal) than in breast (84%), in keeping with a later event there. Around a third of both cohorts' tumours have doubled their genome. +MSK thoracic PDX: 142 patients with a systemic therapy start, 33 with a dated sample on each side of it (a median 301 days apart). Acquired calls outnumber lost ones: CDKN2B (8 acquired against 1 lost), MYC, KEAP1 — none survives correction at 33 patients, and many after-samples are PDX models, whose higher purity calls more than the tissue before them. ```json { - "study": "msk_impact_50k_2026", + "study": "lung_msk_pdx", "cohorts": { - "Colorectal": { - "all": [ - { - "kind": "clinical", - "attribute": "CANCER_TYPE", - "values": [ - "Colorectal Cancer" - ], - "per": "sample" - } - ] - }, - "Breast": { - "all": [ - { - "kind": "clinical", + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "acquired", + "cohorts": [ + "all patients" + ], + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Systemic therapy" + }, + "measure": "driver", + "top": 12 + } + ] +} +``` + +### Example 64: ctDNA against tissue: what a blood test sees of the tumour, and what detecting it says + +MSK lung cancer ctDx (1,127 patients; 682 with plasma and tissue). Plasma catches about two thirds of tissue's EGFR, KRAS and TP53 calls but only a third of STK11, KEAP1 and MET; ctDNA is detected in 68% of plasma patients, and they live about half as long (median 21.8 against 41.5 months, log-rank p≈2e-12) — the prognostic signal the paper reports. + +```json +{ + "study": "nsclc_ctdx_msk_2022", + "cohorts": { + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "ctdna", + "cohorts": [ + "all patients" + ], + "genes": [ + "EGFR", + "KRAS", + "TP53", + "STK11", + "KEAP1", + "PIK3CA", + "BRAF", + "MET", + "ERBB2", + "ALK" + ] + } + ] +} +``` + +### Example 65: Mutual exclusivity that accounts for each tumour's burden (DISCOVER) + +MSK-IMPACT non-small cell lung cancer, 1,680 samples. The oncogenic drivers stay exclusive (KRAS/EGFR 19 together against 170 expected, q≈1e-56), and so do EGFR with STK11 and KEAP1; STK11/KEAP1 co-occur. What moves is what the burden explains: Fisher calls EGFR/TP53 co-occurring (q≈2e-7) because both mark heavily altered tumours, while DISCOVER expects 300 together and sees 294 (q 0.45); KEAP1/TP53, null by Fisher, turns exclusive. + +```json +{ + "study": "msk_impact_2017", + "cohorts": { + "NSCLC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "exclusivity", + "cohorts": [ + "NSCLC" + ], + "genes": [ + "KRAS", + "EGFR", + "BRAF", + "STK11", + "KEAP1", + "TP53" + ] + } + ] +} +``` + +### Example 66: Genomic instability by cancer type: aneuploidy score, FGA and arm-level changes + +MSK-IMPACT 50k: colorectal, breast and pancreatic cancer. Colorectal shows its textbook arms — 20q gained in 60%, 13q in 42%, 18q lost in 53% — and breast 1q and 8q gains with 16q loss. Pancreatic tumours look the most stable (median 2 arms, against 7 and 8), which is mostly purity: their low tumour content pulls every log ratio toward 0, the bias the caption names. + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "Colorectal": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Colorectal Cancer" + ], + "per": "sample" + } + ] + }, + "Breast": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Breast Cancer" + ], + "per": "sample" + } + ] + }, + "Pancreatic": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Pancreatic Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "instability", + "cohorts": [ + "Colorectal", + "Breast", + "Pancreatic" + ] + } + ] +} +``` + +### Example 67: ctDNA with clonal haematopoiesis filtered out: MSK-ACCESS plasma against tissue + +MSK-ACCESS (617 patients; 449 with plasma and tissue). MSK-ACCESS already subtracts the white-cell sample in its own calling, so the filter takes out little: 83 of 2,024 plasma mutation calls in 51 patients, mostly SF3B1, DNMT3A, GNAS and IDH2 by gene and a few TP53 calls seen in the normal. What it removes is plasma-only — AR, RB1 and ATM lose plasma-only calls while every concordant call stays — and ctDNA detected moves from 74% to 73%, with survival by detection unchanged (log-rank p 3.4e-4). + +```json +{ + "study": "msk_access_2021", + "cohorts": { + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "ctdna", + "cohorts": [ + "all patients" + ], + "plasmaPanels": [ + "ACCESS129" + ], + "chFilter": true + } + ] +} +``` + +### Example 68: Clonality, LOH and genome doubling from FACETS + +Colorectal against breast cancer. Tumour suppressors show the classic second hit — TP53 is ~88% clonal with its other allele lost 77–89% of the time, CDH1 in breast 95% — while the oncogene KRAS rarely loses its partner. PIK3CA is markedly more often subclonal in colorectal cancer (68% clonal) than in breast (84%), in keeping with a later event there. Around a third of both cohorts' tumours have doubled their genome. + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "Colorectal": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Colorectal Cancer" + ], + "per": "sample" + } + ] + }, + "Breast": { + "all": [ + { + "kind": "clinical", "attribute": "CANCER_TYPE", "values": [ "Breast Cancer" @@ -3920,7 +4425,7 @@ Colorectal against breast cancer. Tumour suppressors show the classic second hit } ``` -### Example 55: Brain metastasis, with death as a competing risk +### Example 69: Brain metastasis, with death as a competing risk MSK-CHORD, from each patient's day 0, among those without a brain metastasis by then. At five years 24.7% of NSCLC patients have one — where 1 − Kaplan–Meier would have said 30.4% — against 20.6% in breast and 10.8% in colorectal cancer (1 − KM: 14.4%, a third too high, because so many colorectal patients die first). @@ -3988,7 +4493,316 @@ MSK-CHORD, from each patient's day 0, among those without a brain metastasis by } ``` -### Example 56: PSA response to hormone therapy, by TP53 status +### Example 70: Real-world PFS on immunotherapy in lung cancer, by KRAS status + +MSK-CHORD non-small cell lung cancer, KRAS-mutant against KRAS wild-type (profiled for KRAS), from the first Immuno record. About a third of each cohort was treated. Median real-world PFS is about 2.6 months in both groups and the curves lie on top of each other (log-rank p ≈ 0.98): KRAS status alone does not mark who progresses on immunotherapy here. Progression is read from notes, so it is recorded densely; switching to time to next treatment gives medians near a year. + +```json +{ + "study": "msk_chord_2024", + "cohorts": { + "NSCLC, KRAS mutant": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + }, + { + "kind": "oql", + "q": "KRAS: MUT" + } + ] + }, + "NSCLC, KRAS wild-type": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + }, + { + "not": { + "kind": "oql", + "q": "KRAS:" + } + }, + { + "kind": "profiled", + "type": "MUTATION", + "gene": "KRAS" + } + ] + } + }, + "views": [ + { + "kind": "txendpoints", + "cohorts": [ + "NSCLC, KRAS mutant", + "NSCLC, KRAS wild-type" + ], + "endpoint": "rwpfs", + "anchor": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + } + } + ] +} +``` + +### Example 71: From progression to death in colorectal cancer, by stage + +MSK-CHORD colorectal cancer, highest recorded stage 1–3 against stage 4. Stage 4 patients progress over four times as fast (HR ≈ 4.4) and, once progressed, die about 1.7 times as fast; death without a recorded progression is rare in both. So the stage-4 survival gap comes mostly through faster progression, and partly through worse survival after it. About a third of each cohort had a progression recorded before their first sequencing and starts in the progression state; set prevalent to exclude to follow only patients not yet progressed. + +```json +{ + "study": "msk_chord_2024", + "cohorts": { + "Colorectal, stage 1-3": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Colorectal Cancer" + ], + "per": "sample" + }, + { + "kind": "clinical", + "attribute": "STAGE_HIGHEST_RECORDED", + "values": [ + "Stage 1-3" + ] + } + ] + }, + "Colorectal, stage 4": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Colorectal Cancer" + ], + "per": "sample" + }, + { + "kind": "clinical", + "attribute": "STAGE_HIGHEST_RECORDED", + "values": [ + "Stage 4" + ] + } + ] + } + }, + "views": [ + { + "kind": "multistate", + "cohorts": [ + "Colorectal, stage 1-3", + "Colorectal, stage 4" + ] + } + ] +} +``` + +### Example 72: Months of life gained on immunotherapy with a high tumour mutational burden, with landmarks + +Samstein et al.'s immunotherapy cohort (every patient treated with immune checkpoint inhibitors; OS from the first treatment), TMB under 10 against 10 or more mutations per megabase. In the first 24 months TMB-high patients lived about 3.3 months longer on average (17.7 against 14.5 months, ratio 1.23), and the gain holds at about 3.2–3.6 months among those still alive at 6 and at 12 months — the benefit is not only early. + +```json +{ + "study": "tmb_mskcc_2018", + "cohorts": { + "TMB < 10": { + "all": [ + { + "kind": "clinical", + "attribute": "TMB_NONSYNONYMOUS", + "range": { + "lt": 10 + }, + "per": "sample" + } + ] + }, + "TMB ≥ 10": { + "all": [ + { + "kind": "clinical", + "attribute": "TMB_NONSYNONYMOUS", + "range": { + "gte": 10 + }, + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "rmst", + "cohorts": [ + "TMB < 10", + "TMB ≥ 10" + ], + "tau": 24, + "landmarkMonths": [ + 0, + 6, + 12 + ] + } + ] +} +``` + +### Example 73: Which mutations change survival on immunotherapy in lung cancer? + +MSK-CHORD non-small cell lung cancer, 7,809 patients, 2,666 with an immunotherapy record. STK11 and KEAP1 mutations are adverse among the treated (HR 1.48 and 1.41), as reported for checkpoint inhibitors; TP53 and SMARCA4 are strongly adverse only among the untreated (HR 1.72 and 1.88 against about 1.0 and 1.1 treated; interaction q < 1e-4) — in keeping with their link to tumour mutational burden, though immortal time and who gets treated can shape such contrasts too. + +```json +{ + "study": "msk_chord_2024", + "cohorts": { + "NSCLC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "predictive", + "cohorts": [ + "NSCLC" + ], + "exposure": { + "eventType": "Treatment", + "key": "SUBTYPE", + "value": "Immuno" + }, + "genes": [ + "STK11", + "KEAP1", + "KRAS", + "TP53", + "EGFR", + "SMARCA4" + ] + } + ] +} +``` + +### Example 74: A tumour mutational burden cut-point for survival on immunotherapy + +Samstein et al. 2019's 1,661 patients treated with checkpoint inhibitors (tmb_mskcc_2018). The best overall cut is TMB > 14.4 mutations/Mb: the top 19% survive markedly longer (HR 0.45, median 47 against 15 months), corrected p ≈ 0.001 against a naive 9.5e-15 that ignores the search. Per cancer type the best cut moves — 18.6 in NSCLC, 9.8 in melanoma, 11.1 in colorectal — and in renal cell carcinoma, oesophagogastric cancer and glioma no cut survives the correction, the paper's point that one threshold does not fit every histology. Every cut here is optimistic and would need validation in another cohort. + +```json +{ + "study": "tmb_mskcc_2018", + "cohorts": { + "all patients": { + "all": [] + } + }, + "views": [ + { + "kind": "cutpoint", + "cohorts": [ + "all patients" + ], + "attribute": "TMB_NONSYNONYMOUS", + "stratifyBy": "CANCER_TYPE" + } + ] +} +``` + +### Example 75: HLA class I homozygosity and HLA loss in lung cancer, and survival + +MSK-IMPACT 50K, non-small cell lung cancer (6,633 patients; 5,901 typed at all three loci). 22% are homozygous at one locus or more, about the share Chowell et al. report; in this cohort, not restricted to immunotherapy, homozygosity does not move overall survival (HR 1.02). HLA LOH, called in 30% of the 4,158 patients with a call, goes with shorter survival (median 32 against 48 months, HR 1.35, 1.31 adjusted for TMB) — tumour biology, not a treatment effect. + +```json +{ + "study": "msk_impact_50k_2026", + "cohorts": { + "NSCLC": { + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "hla", + "cohorts": [ + "NSCLC" + ], + "covariates": [ + "TMB_SCORE" + ] + } + ] +} +``` + +### Example 76: HLA class I homozygosity and survival on checkpoint blockade in melanoma + +Van Allen 2015 (ipilimumab), Snyder 2014 (ipilimumab) and Hugo 2016 (anti-PD-1): 212 patients, 22% homozygous at a locus or more. Homozygous patients have a median overall survival of 12 against 22 months (HR 1.22, 95% CI 0.82–1.82) — the direction Chowell et al. found pooling these and other cohorts, though 212 patients cannot show it alone. These studies record no HLA LOH, so that split says so. + +```json +{ + "study": "skcm_dfci_2015", + "cohorts": { + "melanoma on checkpoint blockade": { + "studies": [ + "skcm_dfci_2015", + "skcm_mskcc_2014", + "mel_ucla_2016" + ], + "all": [] + } + }, + "views": [ + { + "kind": "hla", + "cohorts": [ + "melanoma on checkpoint blockade" + ] + } + ] +} +``` + +### Example 77: PSA response to hormone therapy, by TP53 status Prostate cancer in MSK-CHORD, from the first hormone therapy. PSA falls by a median of more than 95% within three months in both groups, but 93.0% of TP53 wild-type patients reach PSA50 against 85.8% of TP53-mutant ones (Fisher p = 1.4e-6), and the mutant group's upper quartile turns back upward within a year. @@ -4049,7 +4863,7 @@ Prostate cancer in MSK-CHORD, from the first hormone therapy. PSA falls by a med } ``` -### Example 57: A survival risk model from mutated genes in lung cancer +### Example 78: A survival risk model from mutated genes in lung cancer Non-small cell lung cancer in MSK-CHORD. The model keeps KEAP1, STK11, SMARCA4 and TP53 as adverse and EGFR as favourable, as the literature does, and its risk thirds separate clearly; a held-out C-index near 0.61 says mutations alone predict survival only modestly. Five runs keep the example quick; OncoCast recommends fifty. Its Table 1 (`describe`) shows who is in each third: the high-risk third is more often male, a former or current smoker and at stage 4 than the low-risk third. KEAP1 and TP53 are marked “in the model” — the score is built from them, so their enrichment is by construction and gets no q-value — and ZFHX3, which not every panel covers, is counted over each group's patients whose panel does. @@ -4095,7 +4909,59 @@ Non-small cell lung cancer in MSK-CHORD. The model keeps KEAP1, STK11, SMARCA4 a } ``` -### Example 58: Custom data: a curated biopsy site on every sample +### Example 79: A lung cancer risk model, validated on another study + +Fitted on the 1,440 non-small cell lung cancer patients of MSK-IMPACT 2017 (ten runs; KEAP1, STK11 and TP53 adverse, EGFR favourable), scored on the 7,809 of MSK-CHORD. The C-index holds at 0.607 (95% CI 0.597–0.616) against 0.613 held out in training, and the training thresholds still separate the new cohort: median survival 66 months in the low-risk group against 33 and 21 months (HR 1.63 and 2.12, log-rank p ≈ 1e-72). Both studies are from MSK, so some patients may be in both under different identifiers; a study from another centre would be the stronger test. + +```json +{ + "study": "msk_impact_2017", + "cohorts": { + "NSCLC (MSK-IMPACT 2017)": { + "studies": [ + "msk_impact_2017" + ], + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + }, + "NSCLC (MSK-CHORD)": { + "studies": [ + "msk_chord_2024" + ], + "all": [ + { + "kind": "clinical", + "attribute": "CANCER_TYPE", + "values": [ + "Non-Small Cell Lung Cancer" + ], + "per": "sample" + } + ] + } + }, + "views": [ + { + "kind": "riskmodel", + "cohorts": [ + "NSCLC (MSK-IMPACT 2017)" + ], + "runs": 10, + "validateOn": "NSCLC (MSK-CHORD)" + } + ] +} +``` + +### Example 80: Custom data: a curated biopsy site on every sample luad_mskimpact_2021 is 93 EGFR-mutant lung adenocarcinomas with 186 primary and metastatic samples. The demo upload gives every sample a BIOPSY_SITE, grouped from the study's own SAMPLE_TYPE and free-text METASTATIC_SITE the way a reviewer would (scripts/gen-custom-demo.ts), standing in for a clinician-curated annotation. "CNS met" filters on it: 8 patients. Ordered by site, the oncoprint puts each patient's primary and metastases in their own groups. Fraction of the genome altered has a median of 0.47 in the 11 CNS samples and 0.36 in liver, against 0.21 in the 76 primaries. Split by site, TP53 is altered in 7 of 8 CNS-met patients (87.5%) against 65.6% of the patients with a primary sample. The split arms overlap, because a patient whose primary and metastasis were both sequenced belongs to both arms. With 8 to 61 patients per arm, these are illustrations, not findings. @@ -4178,7 +5044,7 @@ Runs on custom data: `luad-biopsy-site` (LUAD biopsy site (demo), 186 samples, c } ``` -### Example 59: Custom data: single-cell features of ovarian tumours (MSK SPECTRUM) +### Example 81: Custom data: single-cell features of ovarian tumours (MSK SPECTRUM) msk_spectrum_tme_2022 is 42 untreated high-grade serous ovarian cancers; 41 had scRNA-seq at several anatomical sites. scripts/gen-spectrum-sc-demo.py reads the public CELLxGENE cell annotations (cell states, per-cell pathway scores, per-cell 6p allele fraction) and summarises them per patient (solid sites pooled; ADX_ adnexal, NADX_ non-adnexal) and per cBioPortal sample (the scRNA-seq site of the same patient, 61 of 82 samples). Dysfunctional T/NK cells (TNK_DYSFUNC) have a median of 10.8% of T/NK cells in the 16 HRD-Dup tumours against 6.0% in the 14 FBI tumours, and naive/central-memory cells the reverse (7.5% against 11.1%), as in the paper's Fig. 4b. In adnexal cancer cells, JAK-STAT pathway activity has a median of 0.03 in HRD-Dup against -0.57 in FBI (Fig. 3e), and cancer-cell and T/NK-cell JAK-STAT correlate across patients (Spearman 0.74, Fig. 4e). Clonal 6p loss of heterozygosity (over 80% of cancer cells) is called in 4 patients, 3 of them HRD-Dup — the paper reports 4 of 41. By site, T cells are a median 43% of immune cells in the 22 adnexal samples against 54% in the 18 omental ones. These are summaries of public cell metadata, not the paper's statistical models. @@ -4295,7 +5161,7 @@ Runs on custom data: `spectrum-sc-composition` (SPECTRUM scRNA: cell-type compos } ``` -### Example 60: Composition: T and NK cell states by mutational signature (MSK SPECTRUM) +### Example 82: Composition: T and NK cell states by mutational signature (MSK SPECTRUM) The 36 SPECTRUM patients with a consensus signature other than Undetermined, split as the paper splits them. Dysfunctional CXCL13+ CD8 T cells are a mean 7.0% of T/NK cells in the 16 HRD-Dup patients against 4.9% in HRD-Del and 4.4% in FBI, and naive CD4 cells the reverse (15.2% against 19.6% and 20.1%) — the direction of the paper's Fig. 4b. The clearest single difference is cycling T/NK cells, higher in HRD-Dup (8.6% against 5.7%; Welch p = 0.003, q = 0.08 across the 30 comparisons); none reaches q < 0.05 at this size, which the matrix shows as no outlined dot. Per sample, from the study's cell-type fractions, T cells are 24% of HRD-Dup samples' cells against 32% of FBI's. The paper fits a binomial model on cell counts with site and sort as covariates; this view compares shares between groups and says so. @@ -4375,7 +5241,7 @@ Runs on custom data: `spectrum-sc-composition` (SPECTRUM scRNA: cell-type compos } ``` -### Example 61: Composition: adenomas and serrated polyps, from a generic single-cell summary (HTAN Vanderbilt) +### Example 83: Composition: adenomas and serrated polyps, from a generic single-cell summary (HTAN Vanderbilt) crc_hta11_htan_2021 is 52 patients with colorectal polyps (Chen et al., Cell 2021). The summary covers 55 of its 61 samples; its counts equal the study's own absolute_counts profile for all 670 sample and cell-type pairs it reports. Per sample, adenoma-specific cells (ASC) are 32.1% of the epithelium in the 30 conventional adenomas against 4.1% in the 20 serrated polyps (q = 2e-5), and serrated-specific cells (SSC) 1.5% against 19.7% (q = 0.003): the two paths to malignancy the paper describes. Stem cells (STM) differ little here (3.1% against 2.5%). The study's own fractions, over every cell type, give the same picture (ASC 29.9% against 3.7%). Cohorts are patients, so a patient with polyps of both types counts in both; 5 of the 52 have both. @@ -4444,7 +5310,7 @@ Runs on custom data: `hta11-sc-p` (HTAN Vanderbilt colorectal polyp scRNA-seq (C } ``` -### Example 62: Cancer types summary: RAS genes across 50,000 tumours +### Example 84: Cancer types summary: RAS genes across 50,000 tumours KRAS, NRAS and HRAS across MSK-IMPACT 50K. Pancreatic cancer leads at 82%, appendiceal and colorectal follow, and the bars show where RAS is amplified rather than mutated — germ cell tumours and esophagogastric cancer. @@ -4473,7 +5339,7 @@ KRAS, NRAS and HRAS across MSK-IMPACT 50K. Pancreatic cancer leads at 82%, appen } ``` -### Example 63: Cohort overlap: how four groups share their patients +### Example 85: Cohort overlap: how four groups share their patients TP53-mutant, KRAS-mutant, immunotherapy-treated and NSCLC patients in MSK-CHORD. 5,314 TP53-mutant patients belong to none of the other three groups; 435 are in all four. @@ -4543,7 +5409,7 @@ TP53-mutant, KRAS-mutant, immunotherapy-treated and NSCLC patients in MSK-CHORD. } ``` -### Example 64: Alteration enrichment: what separates colorectal from pancreatic tumours +### Example 86: Alteration enrichment: what separates colorectal from pancreatic tumours Microsatellite-stable colorectal against pancreatic cancer in MSK-CHORD. APC, TCF7L2, PIK3CA, FBXW7 and SOX9 mark colorectal cancer; KRAS and CDKN2A mark pancreatic cancer. @@ -4608,7 +5474,7 @@ Microsatellite-stable colorectal against pancreatic cancer in MSK-CHORD. APC, TC } ``` -### Example 65: Mutational signatures: what UV light and tobacco leave behind +### Example 87: Mutational signatures: what UV light and tobacco leave behind Melanoma against non-small cell lung cancer in MSK-IMPACT 50K. The UV signature (7) makes up about half of a melanoma's mutations and almost none of a lung cancer's; the smoking signature (4) is the reverse. @@ -4654,7 +5520,7 @@ Melanoma against non-small cell lung cancer in MSK-IMPACT 50K. The UV signature } ``` -### Example 66: Co-expression: the genes that rise and fall with ESR1 +### Example 88: Co-expression: the genes that rise and fall with ESR1 TCGA PanCancer Atlas breast cancer. The oestrogen-receptor programme lines up behind ESR1 — CCDC170 (its neighbour on 6q25, often co-amplified), THSD4, FOXA1, TBC1D9 — and the genes of basal-like tumours fall away from it. @@ -4679,7 +5545,7 @@ TCGA PanCancer Atlas breast cancer. The oestrogen-receptor programme lines up be } ``` -### Example 67: Clinical data: every pancreatic cancer sample, oldest patients first +### Example 89: Clinical data: every pancreatic cancer sample, oldest patients first Pancreatic cancer in MSK-CHORD, sorted by age, highest first. @@ -4716,7 +5582,7 @@ Pancreatic cancer in MSK-CHORD, sorted by age, highest first. } ``` -### Example 68: A patient's timeline: treatments, diagnoses, samples and lab values from diagnosis +### Example 90: A patient's timeline: treatments, diagnoses, samples and lab values from diagnosis MSK-CHORD breast cancer patients with the most timeline events first; P-0000012 drawn, with chemotherapy folded into one row, and P-0000012's alterations beside it. @@ -4769,7 +5635,7 @@ MSK-CHORD breast cancer patients with the most timeline events first; P-0000012 } ``` -### Example 69: A patient's mutations, copy-number alterations and structural variants +### Example 91: A patient's mutations, copy-number alterations and structural variants MSK-CHORD prostate cancer patients, those with the most structural variants first; P-0024249's tables, mutations kept to OncoKB-oncogenic calls. @@ -4817,7 +5683,7 @@ MSK-CHORD prostate cancer patients, those with the most structural variants firs } ``` -### Example 70: KRAS codon 12/13 mutations in colorectal cancer, clonal-looking calls first +### Example 92: KRAS codon 12/13 mutations in colorectal cancer, clonal-looking calls first MSK-CHORD patients with a colorectal cancer sample: their KRAS G12/G13 calls at an allele frequency of 0.2 or more, highest first. diff --git a/e2e.cjs b/e2e.cjs index 1d62077..73876d5 100644 --- a/e2e.cjs +++ b/e2e.cjs @@ -685,6 +685,8 @@ async function main() { })).toString("base64url")); await page.waitForSelector("canvas", { timeout: 300000 }); await page.waitForTimeout(1500); + await page.locator("canvas").first().evaluate((el) => el.scrollIntoView({ block: "start" })); + await page.waitForTimeout(300); const box = await page.locator("canvas").first().boundingBox(); const y = box.y + 40; await page.mouse.move(box.x + 400, y); @@ -728,6 +730,9 @@ async function main() { })).toString("base64url")); await page.waitForSelector("canvas", { timeout: 300000 }); await page.waitForTimeout(2500); + // the label gutter must be on screen for a hover to land: the add-view panel above it grows with every view kind + await page.locator("canvas").first().evaluate((el) => el.scrollIntoView({ block: "start" })); + await page.waitForTimeout(300); const box = await page.locator("canvas").first().boundingBox(); // clinical rows (# Samples per Patient, Profiled for ...) sit above the genes, as in the // portal, so walk the gutter labels down (header 22px, rows 20 + 3px) to the merged row's diff --git a/eslint.config.mjs b/eslint.config.mjs index f1fb28f..efcabc2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -17,7 +17,7 @@ import path from "node:path"; // another. What two kinds need goes into a shared module, added here. const SHARED_VIEW_MODULES = { "lib/views": ["run", "shared", "rows", "covariates"], - "lib/figures": ["common", "volcano", "bars", "forest"], + "lib/figures": ["common", "volcano", "bars", "forest", "km"], }; const kindBoundary = { meta: { type: "problem", schema: [] }, diff --git a/lib/custom-data-names.ts b/lib/custom-data-names.ts index 238835b..15ef89b 100644 --- a/lib/custom-data-names.ts +++ b/lib/custom-data-names.ts @@ -19,6 +19,8 @@ export const CUSTOM_PREFIX = "custom:"; /** A built-in attribute name, or a custom one. Every attribute-name check in the engine uses it. */ export const ATTR_NAME_RE = /^(?:[A-Za-z0-9_]{1,64}|custom:[a-z0-9][a-z0-9-]{0,39}\/[A-Za-z0-9_]{1,64})$/; +/** An attribute name a view setting may hold: ATTR_NAME_RE's two forms, a built-in name also taking "." and "-" (HLA-A1). */ +export const VIEW_ATTR_RE = /^(?:[A-Za-z0-9_.-]{1,64}|custom:[a-z0-9][a-z0-9-]{0,39}\/[A-Za-z0-9_]{1,64})$/; export const LIMITS = { datasets: 10, diff --git a/lib/dsl-reference.ts b/lib/dsl-reference.ts index db885a1..39c6502 100644 --- a/lib/dsl-reference.ts +++ b/lib/dsl-reference.ts @@ -759,6 +759,108 @@ export const EXAMPLES: DslExample[] = [ covariates: [{ source: "alteration", gene: "APC", by: "mutated" }] }] } }, + { + title: "Pan-cancer forest: TP53 mutation and survival in 30 cancer types", + demonstrates: "A `forest` view: one exposure's hazard ratio fit by Cox regression within each cancer type, then pooled by a DerSimonian–Laird random-effects meta-analysis (and the fixed-effect estimate), with τ², I² and Cochran's Q. Patients not profiled for the gene are left out, never counted as wild type; cancer types under 30 patients or 10 deaths are pooled into one disclosed \"Other\" row, each with its own baseline hazard.", + tags: ["forest-plot", "meta-analysis", "pan-cancer"], + note: "TP53 mutation against overall survival across MSK-IMPACT 50K. TP53-mutant patients die sooner in almost every cancer type — prostate (HR 2.5), endometrial (2.9), thyroid (4.1) — but not in glioma (0.86) or melanoma (0.78), where TP53 marks better-prognosis subtypes. Pooled, the random-effects HR is 1.63 (1.42–1.87), but I² is 93%: the cancer types disagree far beyond chance, so the pooled number is an average over different effects, not one effect.", + doc: { + study: "msk_impact_50k_2026", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "forest", cohorts: ["all patients"], exposure: { source: "alteration", gene: "TP53" }, strata: "CANCER_TYPE" }] + } + }, + { + title: "Adjusted enrichment: early-onset colorectal cancer, adjusted for MSI and TMB", + demonstrates: "An `adjusted` view: every gene mutated in at least 5% of the patients profiled for it, compared between two cohorts by a logistic regression of mutated on cohort and covariates (here MSI status and tumour mutation burden), its adjusted odds ratio beside the raw one and the Fisher test, Benjamini–Hochberg q on the adjusted p. Panel-aware per gene: a patient whose panel omits a gene is left out of that gene.", + tags: ["covariates", "volcano", "early-onset"], + note: "Colorectal cancers in MSK-IMPACT 50K diagnosed before 50 against at 50 or later. Early-onset tumours carry BRAF less often (7.4% against 12.9%; adjusted OR 0.52) and RNF43 less often (0.66), and TP53 (77% against 72%; 1.31) and TCF7L2 (1.40) more often — 4 of 56 genes at q < 0.05 once MSI status and TMB are adjusted for, as in Cercek et al. (JNCI 2021).", + doc: { + study: "msk_impact_50k_2026", + cohorts: { + "Early-onset CRC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Colorectal Cancer"], per: "sample" }, { kind: "clinical", attribute: "AGE_AT_DX", op: "<", value: 50 }] }, + "Average-onset CRC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Colorectal Cancer"], per: "sample" }, { kind: "clinical", attribute: "AGE_AT_DX", op: ">=", value: 50 }] } + }, + views: [{ kind: "adjusted", cohorts: ["Early-onset CRC", "Average-onset CRC"], covariates: ["MSI_TYPE", "TMB_SCORE"] }] + } + }, + { + title: "Clinical actionability: OncoKB levels across 50,000 tumours, and primary against metastasis", + demonstrates: "An `actionability` view: each sequenced patient's highest OncoKB therapeutic level (1, 2, 3A, 3B, 4) and any resistance level over their mutations and copy-number calls, read from the version-pinned store, as stacked bars per cancer type (the largest eight, the rest pooled into a named Other); and for patients with both a primary and a metastatic sample sequenced, the cross-table of their highest level on each side. The store's levels are not cancer-type specific, so the bars are an upper bound — the caveat says so.", + tags: ["oncokb", "precision-oncology", "pan-cancer"], + note: "MSK-IMPACT 50K by cancer type. Because a level is the store's best for the alteration in any cancer type, KRAS-mutant pancreatic cancers count at level 1 (87%) and as R1: an upper bound, not a treatment rate. Of the 2,278 patients with a primary and a metastasis sequenced, 25.5% have a different highest level in the two.", + doc: { + study: "msk_impact_50k_2026", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "actionability", cohorts: ["all patients"], groupBy: "CANCER_TYPE", top: 8 }] + } + }, + { + title: "Driver-negative lung cancer: what is mutated when no canonical driver is", + demonstrates: "A `drivernegative` view: one cohort split into patients with an OncoKB driver in a canonical driver list (here the default NSCLC set: EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1–3), patients with none who were profiled for every listed gene, and those not fully profiled (neither side); then every other gene's mutation frequency in patients compared between driver-negative and driver-positive, Fisher per gene and Benjamini–Hochberg across genes.", + tags: ["drivers", "volcano", "nsclc"], + note: "Non-small cell lung cancer in MSK-CHORD. 2,898 of 7,809 patients (37.1%) carry none of the eleven drivers. Their tumours carry NF1 (12.4% against 5.0%), NFE2L2, KEAP1, RASA1, MAP2K1 and NRAS mutations more often — other routes into the same MAPK and oxidative-stress pathways — while RBM10 goes with the drivers.", + doc: { + study: "msk_chord_2024", + cohorts: { "NSCLC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] } }, + views: [{ kind: "drivernegative", cohorts: ["NSCLC"] }] + } + }, + { + title: "Metastatic organotropism: which mutations go with spread to which organ (MSK-MET)", + demonstrates: "An `organotropism` view: per-organ metastasis flags (MSK-MET's DMETS_DX_, Yes / No) crossed with the twelve most often mutated genes (OncoKB drivers), each cell a Cochran–Mantel–Haenszel test stratified by cancer type with the Mantel–Haenszel odds ratio, Benjamini–Hochberg over the grid — panel-aware, so a patient not profiled for a gene is in neither group.", + tags: ["metastasis", "heatmap", "pan-cancer"], + note: "25,775 patients in MSK-MET (Nguyen et al., Cell 2022). Within cancer types, TP53 goes with metastasis to nearly every organ (liver odds ratio 1.45), EGFR with the brain (1.63), bone and peripheral nerves, APC with liver and lung, and PTEN and ARID1A with less peritoneal and liver spread — the paper's organotropism map from counts alone.", + doc: { + study: "msk_met_2021", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "organotropism", cohorts: ["all patients"] }] + } + }, + { + title: "Brain metastasis in lung cancer by EGFR, from dated tumour sites", + demonstrates: "An `organotropism` view on timeline sites (MSK-CHORD's Diagnosis events, TUMOR_SITE from radiology reports): a gene × site heatmap for six lung-cancer genes, and the cumulative incidence of a first CNS/brain site after the timeline's day 0 for EGFR-mutant against EGFR-wild-type patients, death as a competing risk (Aalen–Johansen, Gray's test).", + tags: ["metastasis", "competing-risks", "nsclc"], + note: "Non-small cell lung cancer in MSK-CHORD. EGFR-mutant tumours carry brain sites more often (OR 1.7) and gain them faster after sequencing: 32% by five years against 23%, Gray's p < 0.001; STK11, KEAP1 and SMARCA4 go with adrenal spread.", + doc: { + study: "msk_chord_2024", + cohorts: { "NSCLC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] } }, + views: [{ kind: "organotropism", cohorts: ["NSCLC"], source: "timeline", genes: ["EGFR", "KRAS", "TP53", "STK11", "KEAP1", "SMARCA4"], incidence: { site: "CNS/Brain", gene: "EGFR" } }] + } + }, + { + title: "Endometrial cancer molecular subtypes by genetic ancestry (POLE, MMRd, p53abn, NSMP)", + demonstrates: "A `molsubtype` view: the endometrial molecular classification (TCGA 2013 / ProMisE / WHO 2020) applied to each patient in its hierarchy — a POLE exonuclease-domain hotspot, else MSI-high (MSI_TYPE), else an OncoKB-driver TP53 mutation, else no specific molecular profile — with patients missing an input counted as not classifiable; subtype shares by ANCESTRY_LABEL (chi-squared) and overall survival by subtype (log-rank).", + tags: ["endometrial", "classifier", "ancestry"], + note: "2,373 endometrial cancers in MSK-IMPACT 50K; 196 have no MSI call. p53-abnormal tumours are 71% of classified patients of African ancestry against 34% of non-Ashkenazi European (as in Weigelt et al. on Black and White patients, PMID 37651310), and survival separates as published: POLE-ultramutated best, p53-abnormal worst (median 29.6 months).", + doc: { + study: "msk_impact_50k_2026", + cohorts: { "Endometrial": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Endometrial Cancer"], per: "sample" }] } }, + views: [{ kind: "molsubtype", cohorts: ["Endometrial"], by: "ANCESTRY_LABEL" }] + } + }, + { + title: "The endometrial classifier against TCGA's own molecular subtypes", + demonstrates: "A `molsubtype` view on TCGA's uterine cohort, where the MSI call is the MSIsensor score (MSI_SENSOR_SCORE, ≥ 10 MSI-high, < 3 stable) and the subtypes are crossed with TCGA's published integrated subtypes (the SUBTYPE attribute) — a check of the panel-based classifier against the original.", + tags: ["endometrial", "classifier", "tcga"], + note: "Of TCGA's 49 POLE tumours 48 are called POLE-ultramutated, 122 of 126 MSI tumours MMR-deficient, 139 of 161 copy-number-high p53-abnormal, and 135 of 145 copy-number-low NSMP.", + doc: { + study: "ucec_tcga_pan_can_atlas_2018", + cohorts: { "UCEC": { all: [] } }, + views: [{ kind: "molsubtype", cohorts: ["UCEC"], by: "SUBTYPE" }] + } + }, + { + title: "Germline carriers in metastatic prostate cancer, and their tumours' second hits", + demonstrates: "A `germline` view: per gene with at least two germline carriers (mutations the study marks GERMLINE), the carriers among patients sequenced for the gene, and the share whose tumour carries a somatic second hit in the same gene (another mutation or a deep deletion) against the somatic rate in non-carriers, Fisher per gene with Benjamini–Hochberg; overall survival of BRCA2 carriers against non-carriers.", + tags: ["inherited", "second-hit", "prostate"], + note: "SU2C/PCF metastatic castration-resistant prostate cancer (Abida et al. 2019), one of the few public studies that deposit germline calls. 17 of 429 patients (4.0%) carry a germline BRCA2 variant, 3 of them with a second somatic BRCA2 mutation against 7.3% of non-carriers; 2 of 6 BRCA1 carriers against 0.5% (q 0.005). Loss of heterozygosity is not assessed, so these are floors. Only 128 patients have OS recorded.", + doc: { + study: "prad_su2c_2019", + cohorts: { "mCRPC": { all: [] } }, + views: [{ kind: "germline", cohorts: ["mCRPC"], minCarriers: 2, survival: { gene: "BRCA2" } }] + } + }, { title: "splitBy: one cohort, auto-split into an arm per receptor subtype", demonstrates: "`splitBy` turns one named cohort into an arm per distinct value of a categorical attribute, live from the data, instead of hand-writing one cohort per value. The arms are scoped to this one view — they never touch the document's own cohort list — but still carry full provenance (each arm's own compiled count/SQL) in the result.", @@ -973,6 +1075,65 @@ export const EXAMPLES: DslExample[] = [ brca_dldccc_2022: { attribute: "TREATMENT_STATUS", a: "Pre-Treatment", b: "Post-Treatment" } } } }] } }, + { + title: "Acquired under therapy: the sample before a treatment against the sample after it", + demonstrates: "An `acquired` view (preview): patients with a sample dated on or before their first systemic therapy and another at least 30 days after it, read off the timeline's sample events — per gene, an oncogenic alteration only after (acquired) or only before (lost), counted only where both samples' panels cover the gene, with an exact McNemar test; with no `genes` it scans every gene acquired in at least 3 patients and draws the most one-sided.", + tags: ["treatment", "resistance", "scan", "preview"], + note: "MSK thoracic PDX: 142 patients with a systemic therapy start, 33 with a dated sample on each side of it (a median 301 days apart). Acquired calls outnumber lost ones: CDKN2B (8 acquired against 1 lost), MYC, KEAP1 — none survives correction at 33 patients, and many after-samples are PDX models, whose higher purity calls more than the tissue before them.", + doc: { + study: "lung_msk_pdx", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "acquired", cohorts: ["all patients"], anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Systemic therapy" }, measure: "driver", top: 12 }] + } + }, + { + title: "ctDNA against tissue: what a blood test sees of the tumour, and what detecting it says", + demonstrates: "A `ctdna` view (preview): patients with a plasma (cfDNA) sample and a tissue sample, per gene covered by both panels, altered in both, tissue only or plasma only — plasma's positive percent agreement with tissue as the reference, with Wilson intervals — then the share of plasma patients with any alteration detected, and overall survival by ctDNA detected against not, with a log-rank test.", + tags: ["liquid biopsy", "prognosis", "preview"], + note: "MSK lung cancer ctDx (1,127 patients; 682 with plasma and tissue). Plasma catches about two thirds of tissue's EGFR, KRAS and TP53 calls but only a third of STK11, KEAP1 and MET; ctDNA is detected in 68% of plasma patients, and they live about half as long (median 21.8 against 41.5 months, log-rank p≈2e-12) — the prognostic signal the paper reports.", + doc: { + study: "nsclc_ctdx_msk_2022", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "ctdna", cohorts: ["all patients"], genes: ["EGFR", "KRAS", "TP53", "STK11", "KEAP1", "PIK3CA", "BRAF", "MET", "ERBB2", "ALK"] }] + } + }, + { + title: "Mutual exclusivity that accounts for each tumour's burden (DISCOVER)", + demonstrates: "An `exclusivity` view (preview): DISCOVER's background — each sample's and gene's probability of alteration fitted from the margins of the whole altered-gene matrix, panel by panel — tests every pair's co-altered count against its Poisson-binomial null, exclusivity in the lower tail and co-occurrence in the upper, beside the plain Fisher test that assumes every tumour is alike.", + tags: ["mutual exclusivity", "co-occurrence", "lung", "preview"], + note: "MSK-IMPACT non-small cell lung cancer, 1,680 samples. The oncogenic drivers stay exclusive (KRAS/EGFR 19 together against 170 expected, q≈1e-56), and so do EGFR with STK11 and KEAP1; STK11/KEAP1 co-occur. What moves is what the burden explains: Fisher calls EGFR/TP53 co-occurring (q≈2e-7) because both mark heavily altered tumours, while DISCOVER expects 300 together and sees 294 (q 0.45); KEAP1/TP53, null by Fisher, turns exclusive.", + doc: { + study: "msk_impact_2017", + cohorts: { "NSCLC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] } }, + views: [{ kind: "exclusivity", cohorts: ["NSCLC"], genes: ["KRAS", "EGFR", "BRAF", "STK11", "KEAP1", "TP53"] }] + } + }, + { + title: "Genomic instability by cancer type: aneuploidy score, FGA and arm-level changes", + demonstrates: "An `instability` view (preview): from each sample's copy-number segments, computed per sample inside the database, the fraction of the genome altered and arm-level gains and losses (an arm called when more than half its length is past ±0.2) — their count is the aneuploidy score, after Taylor et al. 2018 — drawn per cohort as boxes from histograms with a Kruskal–Wallis test, and each arm's gain and loss frequency as a heatmap. Only histograms and per-arm counts leave the database.", + tags: ["copy-number", "aneuploidy", "segments", "preview"], + note: "MSK-IMPACT 50k: colorectal, breast and pancreatic cancer. Colorectal shows its textbook arms — 20q gained in 60%, 13q in 42%, 18q lost in 53% — and breast 1q and 8q gains with 16q loss. Pancreatic tumours look the most stable (median 2 arms, against 7 and 8), which is mostly purity: their low tumour content pulls every log ratio toward 0, the bias the caption names.", + doc: { + study: "msk_impact_50k_2026", + cohorts: { + "Colorectal": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Colorectal Cancer"], per: "sample" }] }, + "Breast": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Breast Cancer"], per: "sample" }] }, + "Pancreatic": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Pancreatic Cancer"], per: "sample" }] } + }, + views: [{ kind: "instability", cohorts: ["Colorectal", "Breast", "Pancreatic"] }] + } + }, + { + title: "ctDNA with clonal haematopoiesis filtered out: MSK-ACCESS plasma against tissue", + demonstrates: "A `ctdna` view (preview) with `chFilter`: plasma mutation calls likely from clonal haematopoiesis — in the canonical CH genes, or seen in the patient's matched white-cell sample at a comparable allele fraction — are left out before agreement with tissue, ctDNA detected and survival are counted, and the result says what each rule removed, gene by gene, beside the unfiltered numbers.", + tags: ["liquid biopsy", "clonal hematopoiesis", "prognosis", "preview"], + note: "MSK-ACCESS (617 patients; 449 with plasma and tissue). MSK-ACCESS already subtracts the white-cell sample in its own calling, so the filter takes out little: 83 of 2,024 plasma mutation calls in 51 patients, mostly SF3B1, DNMT3A, GNAS and IDH2 by gene and a few TP53 calls seen in the normal. What it removes is plasma-only — AR, RB1 and ATM lose plasma-only calls while every concordant call stays — and ctDNA detected moves from 74% to 73%, with survival by detection unchanged (log-rank p 3.4e-4).", + doc: { + study: "msk_access_2021", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "ctdna", cohorts: ["all patients"], plasmaPanels: ["ACCESS129"], chFilter: true }] + } + }, { title: "Clonality, LOH and genome doubling from FACETS", demonstrates: "A `clonality` view: the FACETS allele-specific copy number this warehouse holds for MSK-IMPACT (1.9M mutation calls no other view reads) — per gene, how often a cohort's driver mutations are clonal or subclonal and whether the mutated locus lost its other allele, beside each cohort's whole-genome doubling rate and purity and ploidy distributions, from FACETS-QC-passing samples.", @@ -1002,6 +1163,106 @@ export const EXAMPLES: DslExample[] = [ views: [{ kind: "incidence", cohorts: ["NSCLC", "Breast", "Colorectal"], event: { eventType: "Diagnosis", attrs: [{ key: "TUMOR_SITE", value: "CNS/Brain" }], label: "brain metastasis" } }] } }, + { + title: "Real-world PFS on immunotherapy in lung cancer, by KRAS status", + demonstrates: "A `txendpoints` view (preview): real-world progression-free survival from each patient's first immunotherapy record — to the first progression recorded after it, or death — as Kaplan–Meier curves with the number at risk, medians with 95% intervals and a log-rank test, from (entry, time, event) counts alone. `endpoint` switches it to time to next treatment (the next treatment line, as the sequence view builds lines) or time on treatment.", + tags: ["real-world-endpoints", "timeline-events", "log-rank"], + note: "MSK-CHORD non-small cell lung cancer, KRAS-mutant against KRAS wild-type (profiled for KRAS), from the first Immuno record. About a third of each cohort was treated. Median real-world PFS is about 2.6 months in both groups and the curves lie on top of each other (log-rank p ≈ 0.98): KRAS status alone does not mark who progresses on immunotherapy here. Progression is read from notes, so it is recorded densely; switching to time to next treatment gives medians near a year.", + doc: { + study: "msk_chord_2024", + cohorts: { + "NSCLC, KRAS mutant": { all: [ + { kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }, + { kind: "oql", q: "KRAS: MUT" } + ] }, + "NSCLC, KRAS wild-type": { all: [ + { kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }, + { not: { kind: "oql", q: "KRAS:" } }, { kind: "profiled", type: "MUTATION", gene: "KRAS" } + ] } + }, + views: [{ kind: "txendpoints", cohorts: ["NSCLC, KRAS mutant", "NSCLC, KRAS wild-type"], endpoint: "rwpfs", anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" } }] + } + }, + { + title: "From progression to death in colorectal cancer, by stage", + demonstrates: "A `multistate` view (preview): the illness–death model (Rueda et al., Nature 2019) — alive without progression, alive after it, dead — from each patient's first sequencing (the timeline's day 0 in MSK-CHORD). Per cohort the share in each state month by month (Aalen–Johansen, stacked), the cumulative hazard of each transition (Nelson–Aalen), each transition's rate per 100 person-months, and a Cox hazard ratio per transition against the first cohort, from id-free (progression time, end time, dead) counts.", + tags: ["timeline-events", "progression", "hazard-ratio"], + note: "MSK-CHORD colorectal cancer, highest recorded stage 1–3 against stage 4. Stage 4 patients progress over four times as fast (HR ≈ 4.4) and, once progressed, die about 1.7 times as fast; death without a recorded progression is rare in both. So the stage-4 survival gap comes mostly through faster progression, and partly through worse survival after it. About a third of each cohort had a progression recorded before their first sequencing and starts in the progression state; set prevalent to exclude to follow only patients not yet progressed.", + doc: { + study: "msk_chord_2024", + cohorts: { + "Colorectal, stage 1-3": { all: [ + { kind: "clinical", attribute: "CANCER_TYPE", values: ["Colorectal Cancer"], per: "sample" }, + { kind: "clinical", attribute: "STAGE_HIGHEST_RECORDED", values: ["Stage 1-3"] } + ] }, + "Colorectal, stage 4": { all: [ + { kind: "clinical", attribute: "CANCER_TYPE", values: ["Colorectal Cancer"], per: "sample" }, + { kind: "clinical", attribute: "STAGE_HIGHEST_RECORDED", values: ["Stage 4"] } + ] } + }, + views: [{ kind: "multistate", cohorts: ["Colorectal, stage 1-3", "Colorectal, stage 4"] }] + } + }, + { + title: "Months of life gained on immunotherapy with a high tumour mutational burden, with landmarks", + demonstrates: "An `rmst` view (preview): the restricted mean survival time — the area under each Kaplan–Meier curve up to τ = 24 months, the mean months lived in the first two years — with its standard error, and between the cohorts the difference and ratio of those means with 95% intervals. It needs no proportional hazards, the usual worry with immunotherapy curves. `landmarkMonths` repeats it among the patients alive and followed at 6 and 12 months, each panel's clock restarted there.", + tags: ["immunotherapy", "landmark", "non-proportional-hazards"], + note: "Samstein et al.'s immunotherapy cohort (every patient treated with immune checkpoint inhibitors; OS from the first treatment), TMB under 10 against 10 or more mutations per megabase. In the first 24 months TMB-high patients lived about 3.3 months longer on average (17.7 against 14.5 months, ratio 1.23), and the gain holds at about 3.2–3.6 months among those still alive at 6 and at 12 months — the benefit is not only early.", + doc: { + study: "tmb_mskcc_2018", + cohorts: { + "TMB < 10": { all: [{ kind: "clinical", attribute: "TMB_NONSYNONYMOUS", range: { lt: 10 }, per: "sample" }] }, + "TMB ≥ 10": { all: [{ kind: "clinical", attribute: "TMB_NONSYNONYMOUS", range: { gte: 10 }, per: "sample" }] } + }, + views: [{ kind: "rmst", cohorts: ["TMB < 10", "TMB ≥ 10"], tau: 24, landmarkMonths: [0, 6, 12] }] + } + }, + { + title: "Which mutations change survival on immunotherapy in lung cancer?", + demonstrates: "A `predictive` view (preview): for each gene, a Cox model of overall survival on the gene, exposure to a treatment (any Immuno record) and their interaction — the gene's hazard ratio among the treated and among the untreated side by side, and whether they differ (interaction p, BH q). Each gene is fitted over the patients whose panel covers it.", + tags: ["predictive-biomarker", "interaction", "timeline-events"], + note: "MSK-CHORD non-small cell lung cancer, 7,809 patients, 2,666 with an immunotherapy record. STK11 and KEAP1 mutations are adverse among the treated (HR 1.48 and 1.41), as reported for checkpoint inhibitors; TP53 and SMARCA4 are strongly adverse only among the untreated (HR 1.72 and 1.88 against about 1.0 and 1.1 treated; interaction q < 1e-4) — in keeping with their link to tumour mutational burden, though immortal time and who gets treated can shape such contrasts too.", + doc: { + study: "msk_chord_2024", + cohorts: { + "NSCLC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] } + }, + views: [{ kind: "predictive", cohorts: ["NSCLC"], exposure: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" }, genes: ["STK11", "KEAP1", "KRAS", "TP53", "EGFR", "SMARCA4"] }] + } + }, + { + title: "A tumour mutational burden cut-point for survival on immunotherapy", + demonstrates: "A `cutpoint` view (preview): the value of a numeric marker (here TMB) that best splits overall survival, found by scanning every candidate cut between the 10th and 90th percentiles with the maximally selected log-rank statistic, its p corrected for that search by 1,000 seeded permutations of the marker and set beside the naive one — then the same per cancer type (`stratifyBy`).", + tags: ["biomarker-threshold", "permutation-test", "log-rank"], + note: "Samstein et al. 2019's 1,661 patients treated with checkpoint inhibitors (tmb_mskcc_2018). The best overall cut is TMB > 14.4 mutations/Mb: the top 19% survive markedly longer (HR 0.45, median 47 against 15 months), corrected p ≈ 0.001 against a naive 9.5e-15 that ignores the search. Per cancer type the best cut moves — 18.6 in NSCLC, 9.8 in melanoma, 11.1 in colorectal — and in renal cell carcinoma, oesophagogastric cancer and glioma no cut survives the correction, the paper's point that one threshold does not fit every histology. Every cut here is optimistic and would need validation in another cohort.", + doc: { + study: "tmb_mskcc_2018", + cohorts: { "all patients": { all: [] } }, + views: [{ kind: "cutpoint", cohorts: ["all patients"], attribute: "TMB_NONSYNONYMOUS", stratifyBy: "CANCER_TYPE" }] + } + }, + { + title: "HLA class I homozygosity and HLA loss in lung cancer, and survival", + demonstrates: "An `hla` view (preview): from the germline HLA class I alleles MSK-IMPACT types for each patient, the share homozygous at 0, 1 or 2+ of HLA-A, -B and -C (alleles compared at two fields, A*02:01), and the share whose tumour lost an HLA allele (LOHHLA-style calls); then overall survival by homozygous-at-any-locus against fully heterozygous and by HLA LOH against none, with log-rank tests and Cox hazard ratios, unadjusted and adjusted for tumour mutational burden.", + tags: ["immunogenomics", "hla-genotype", "prognosis", "preview"], + note: "MSK-IMPACT 50K, non-small cell lung cancer (6,633 patients; 5,901 typed at all three loci). 22% are homozygous at one locus or more, about the share Chowell et al. report; in this cohort, not restricted to immunotherapy, homozygosity does not move overall survival (HR 1.02). HLA LOH, called in 30% of the 4,158 patients with a call, goes with shorter survival (median 32 against 48 months, HR 1.35, 1.31 adjusted for TMB) — tumour biology, not a treatment effect.", + doc: { + study: "msk_impact_50k_2026", + cohorts: { "NSCLC": { all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] } }, + views: [{ kind: "hla", cohorts: ["NSCLC"], covariates: ["TMB_SCORE"] }] + } + }, + { + title: "HLA class I homozygosity and survival on checkpoint blockade in melanoma", + demonstrates: "An `hla` view (preview) over three studies pooled: the melanoma checkpoint-inhibitor cohorts that record each patient's HLA-A, -B and -C as one \"A0201/A2402\" value — homozygosity per patient, then overall survival (from the start of treatment, as these studies measure it) by homozygous at any class I locus against fully heterozygous, the log-rank test and Cox model stratified by study.", + tags: ["immunotherapy", "immunogenomics", "hla-genotype", "preview"], + note: "Van Allen 2015 (ipilimumab), Snyder 2014 (ipilimumab) and Hugo 2016 (anti-PD-1): 212 patients, 22% homozygous at a locus or more. Homozygous patients have a median overall survival of 12 against 22 months (HR 1.22, 95% CI 0.82–1.82) — the direction Chowell et al. found pooling these and other cohorts, though 212 patients cannot show it alone. These studies record no HLA LOH, so that split says so.", + doc: { + study: "skcm_dfci_2015", + cohorts: { "melanoma on checkpoint blockade": { studies: ["skcm_dfci_2015", "skcm_mskcc_2014", "mel_ucla_2016"], all: [] } }, + views: [{ kind: "hla", cohorts: ["melanoma on checkpoint blockade"] }] + } + }, { title: "PSA response to hormone therapy, by TP53 status", demonstrates: "A `labs` view: a lab value's course from a treatment start, as % change from each patient's own baseline — median and interquartile band per 4-week bin, from histograms, so any site can contribute — with the best response, the share meeting a response threshold (here PCWG3's PSA50, counted exactly), and a waterfall of every patient's best change where the participant may disclose patient-level values.", @@ -1037,6 +1298,20 @@ export const EXAMPLES: DslExample[] = [ describe: { attributes: ["STAGE_HIGHEST_RECORDED", "GENDER", "SMOKING_PREDICTIONS_3_CLASSES", "CURRENT_AGE_DEID"], genes: ["KEAP1", "TP53", "ZFHX3"] } }] } }, + { + title: "A lung cancer risk model, validated on another study", + demonstrates: "A `riskmodel` view with `validateOn` (preview): the OncoCast-style ensemble is fitted on one study's cohort as usual, then every patient of another cohort — here another study — is scored with the ensemble's mean coefficients and cut at the training risk-group thresholds, with Harrell's C-index (bootstrap interval), Kaplan–Meier curves, the log-rank test and each group's hazard ratio on patients the model never saw.", + tags: ["machine-learning", "external-validation"], + note: "Fitted on the 1,440 non-small cell lung cancer patients of MSK-IMPACT 2017 (ten runs; KEAP1, STK11 and TP53 adverse, EGFR favourable), scored on the 7,809 of MSK-CHORD. The C-index holds at 0.607 (95% CI 0.597–0.616) against 0.613 held out in training, and the training thresholds still separate the new cohort: median survival 66 months in the low-risk group against 33 and 21 months (HR 1.63 and 2.12, log-rank p ≈ 1e-72). Both studies are from MSK, so some patients may be in both under different identifiers; a study from another centre would be the stronger test.", + doc: { + study: "msk_impact_2017", + cohorts: { + "NSCLC (MSK-IMPACT 2017)": { studies: ["msk_impact_2017"], all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] }, + "NSCLC (MSK-CHORD)": { studies: ["msk_chord_2024"], all: [{ kind: "clinical", attribute: "CANCER_TYPE", values: ["Non-Small Cell Lung Cancer"], per: "sample" }] } + }, + views: [{ kind: "riskmodel", cohorts: ["NSCLC (MSK-IMPACT 2017)"], runs: 10, validateOn: "NSCLC (MSK-CHORD)" }] + } + }, { title: "Custom data: a curated biopsy site on every sample", demonstrates: "An uploaded, sample-level annotation (`custom:luad-biopsy-site/BIOPSY_SITE`) used like the study's own attributes: as a cohort filter, an oncoprint track that orders the columns, a plot axis, and a `splitBy` that compares gene frequencies across sites. The rows are demo data bundled with the app, so this example runs without logging in; a real upload is stored on the server.", @@ -1551,6 +1826,8 @@ patient — so the arm counts summing to more than the base cohort is expected, | \`survival\` | \`endpoint?:"OS"|"PFS"|…, monthsAttribute?/statusAttribute?, anchor?:{eventType,key?,value?,occurrence?}, cohorts:[names]\` | Kaplan–Meier on OS_MONTHS/OS_STATUS, plus a two-sided **log-rank** test when exactly 2 cohorts, or the **k-group generalization** (a (k-1)-df chi-squared from the full covariance matrix, not a scalar variance — the same statistic R's \`survdiff()\` reports) when 3+, testing whether the groups differ AT ALL rather than which pair does. Across studies either test is **stratified by study** (each study compared only against itself, so differing case mix cannot masquerade as a cohort effect); the pooled p is reported alongside. \`anchor\` resets the clock to a timeline event (first chemotherapy, initial diagnosis) instead of the study's day 0, and **applies left truncation automatically**: a patient joins the risk set when the study could first observe them, never at the anchor \u2014 otherwise everyone appears to survive from the anchor to their own entry, which is exactly what put them in the study. The reported entry distribution warns when an anchor is matching earlier events than intended. Cross-study baselines are not harmonized. **The test is refused, not computed, when any arm has fewer than 10 patients** — the KM curves still draw (they are descriptive), but a chi-squared from a handful of patients reads as a real result whatever its p-value says, so it is named as refused rather than reported. \`monthsAttribute\`/\`statusAttribute\` override the \`_MONTHS\`/\`_STATUS\` naming convention with exact column names, for a study whose outcome columns don't follow it (some do not — e.g. \`PFS_MONTHS_FIRST_LINE_TX\`/\`PROGRESSION_STATUS\`); both must be given together, and \`endpoint\` becomes purely a display label rather than something column names are derived from | | \`cox\` | \`endpoint?:"OS"|"PFS"|..., monthsAttribute?/statusAttribute?, covariates:[axis, ...(max 8)], stratifyByStudy?, stratifyByCohort?, cutoffMonths?, cohorts:[names]\` | multivariate Cox proportional-hazards regression: several covariates fit simultaneously, reported as hazard ratios with Wald confidence intervals, drawn as a forest plot. Each \`covariate\` is a plot axis (clinical, molecular, or a gene's — or a gene SET's — alteration status; see Plot axes below). Unlike every other view, this is fit from real per-patient rows within each LOCAL study, not pooled aggregate counts, so a remote or aggregate-only participant cannot contribute to it and is named as excluded rather than silently dropped. \`stratifyByStudy\` fits a separate baseline hazard per study instead of pooling them (one model, per-study risk sets). \`stratifyByCohort\` goes further: with 2+ named cohorts, it fits each cohort as its own INDEPENDENT model — own coefficients, own reference-level choice — instead of one pooled fit, drawn as one forest-plot panel per cohort sharing a single hazard-ratio scale so the effects are directly comparable; the right tool when a covariate's EFFECT, not just the cohorts' case mix, might genuinely differ between them (pooling would average that difference away). A cohort that can't be fit (too few patients, no variation) is named and skipped rather than blocking the rest; a no-op with only one cohort. A categorical covariate is dummy-coded against a reference level: a mutated-vs-not alteration axis (\`by:"mutated"\`, the default) always defaults to \`"Not mutated"\` regardless of which side is more frequent — a gene altered in a minority of patients and one altered in a majority would otherwise silently end up measured in opposite directions, purely from how common each gene's alteration happens to be; every other categorical (a mutation-type/CNA axis, a clinical attribute) defaults to its most-frequently-observed level. Either way, a covariate axis may add \`"reference":""\` to declare the level explicitly instead — a \`reference\` naming a level the data doesn't have is ignored (falls back to the same default as if none were declared) rather than silently matching nothing. \`monthsAttribute\`/\`statusAttribute\` are the same naming-convention override as \`survival\`, for a study whose outcome columns don't follow \`_MONTHS\`/\`_STATUS\` | | \`logistic\` | \`outcome:axis, outcomeEvent?:"", covariates:[axis, ...(max 8)], cohorts:[names]\` | multivariate logistic regression: a BINARY outcome (not time-to-event \u2014 no endpoint/months convention here) regressed on several covariates at once, reported as odds ratios with Wald confidence intervals, drawn as a forest plot the same shape as \`cox\`'s. \`outcome\` is a plot axis, exactly like a covariate (clinical, molecular, or alteration status). A categorical outcome (a clinical attribute, or an alteration axis) needs \`outcomeEvent\` naming which observed value counts as the event (1) \u2014 every other observed value collapses into "not event" (0), and which values got collapsed is disclosed in the caveat; omitting it, or naming a value never observed, is refused with the actual observed values listed rather than guessed at. A numeric outcome must already be coded exactly 0/1 (refused otherwise) and must NOT be given \`outcomeEvent\`. Covariates use the exact same dummy-coding and reference-level rules as \`cox\` (see above): a mutated-vs-not axis defaults to \`\"Not mutated\"\`, others default to the most frequent level, \`reference\` overrides either. Same patient-level-only boundary as \`cox\` | +| \`forest\` | \`exposure:axis, exposed?:"", threshold?:number, endpoint?:"OS"\|…, monthsAttribute?/statusAttribute?, strata?:"CANCER_TYPE"\|\|"study"\|"cohort", covariates?:[axis ×≤4], minPatients?:30, minEvents?:10, maxStrata?:30, small?:"pool"\|"drop", cohorts:[names]\` | **a pan-cancer forest / random-effects meta-analysis of one exposure's hazard ratio**: the cohorts' patients (pooled; per cohort with \`strata:"cohort"\`) are split by \`strata\` — a categorical attribute (default CANCER_TYPE; a patient whose samples disagree is left out, counted), the study, or the view's cohorts — and within each a Cox model (Efron ties) is fit for exposed vs not, univariate or adjusted for \`covariates\` (coded as \`cox\` codes them, within each row). The exposure is a plot axis: a gene's alteration (exposed = altered; a patient not profiled for the gene is left out, never unaltered), a categorical attribute with \`exposed\` naming the level, or a numeric one with \`threshold\` (exposed at or above it). Strata under \`minPatients\` patients or \`minEvents\` events, and past the \`maxStrata\` largest, are pooled into one "Other" row (each keeping its own baseline hazard) or, with \`small:"drop"\`, listed as left out; a row without an exposed and an unexposed event, or whose fit does not converge, is listed with the reason. The rows are pooled by inverse variance on log HR: fixed effect, and DerSimonian–Laird random effects with τ², I², Cochran's Q and its p. Fit from per-patient rows inside each local study (patient-level participants only). | +| \`adjusted\` | \`covariates?:["" ×≤6], measure?:"any"\|"driver", minShare?:0.05, minAltered?:10, label?:12, cohorts:[2 names]\` | **covariate-adjusted alteration enrichment between two cohorts**: every gene mutated (called mutations; OncoKB drivers with \`measure:"driver"\`) in at least \`minShare\` of the patients profiled for it and in at least \`minAltered\`, both cohorts together — at most 200 genes, refused past that with the count — gets a logistic regression of mutated on cohort (the first = 1) and the \`covariates\` (clinical attributes: a number as recorded, a category against its commonest value, as \`cox\`/\`logistic\` code them; a sample attribute is the patient's first sample's; patients lacking one are left out, counted). Reported: the cohort term's adjusted odds ratio, Wald 95% CI and p, Benjamini–Hochberg q over the genes fit, beside the raw odds ratio and two-sided Fisher p on the same patients. Per patient and per gene, panel-aware: a patient is in a gene's model only when a mutation panel of theirs covers it (or whole-exome). A covariate category in which no patient (or every patient) is mutated is merged into its reference for that gene; a gene whose fit still separates or does not converge is listed, never dropped. Volcano of adjusted log2 OR against −log₁₀ q, and the top genes' table. Patient-level participants only. | | \`course\` | \`eventTypes:[\u22646], anchor?:{eventType,key?,value?,occurrence?}, binMonths?:3, fromMonths?:-12, toMonths?:60, cohorts:[names]\` | the **clinical course**: per time bin, the share of the cohort in each event state. Two things it refuses to fake. (1) The denominator is the patients still under observation in that bin \u2014 first recorded event to last contact (the later of their last event and their OS follow-up) \u2014 never the whole cohort, which would make a curve fall merely because follow-up ran out. (2) \`anchor\` is optional for one study but **required across studies**: day 0 is birth in some studies and first sequencing in others, and nothing in the schema records which, so raw day 0 compares incomparable clocks. Event types that carry real durations are drawn as a state the patient is IN; the rest as an event that HAPPENED \u2014 read from the data, not from the name. Bins are half-open [t, t+w). Event-name spellings differing only in case are folded (\`Treatment\`/\`TREATMENT\`) and the fold is named in the caveat; nothing else is merged | | \`sequence\` | \`labelKey?:"SUBTYPE", eventType?:"treatment", lineClasses?:"systemic"|"all"|[classes], exclude?:[values], lineGap?:90, maxSteps?:3, topN?:8, cohorts:[names]\` | **what follows what**: therapy transitions as lines, drawn as a Sankey. Records are first grouped into LINES \u2014 therapies less than \`lineGap\` days apart are one line, labelled by their whole combination (sorted, so \`A + B\` and \`B + A\` are one regimen). That grouping is the point: a patient has a median of 6 treatment records and 4 distinct agents, and combination therapy is several records describing ONE decision. Paths that end flow into TWO explicit terminals, never one: **no further line** (the study watched for at least one \`lineGap\` and saw no further therapy) and **record ends here** (it did not, so nothing can be concluded). Measured on MSK-CHORD, 68% of patients have under three months of observation after their last therapy and the median is 15 days \u2014 so collapsing the two would turn record-keeping into an apparent clinical decision. Flows out of a node sum to it, so nobody vanishes. Up to two cohorts are drawn as panels sharing one scale and one colour per regimen. Lines are the shared definition (see "Treatment line (derived)" above): systemic-therapy records by default, \`lineClasses\` \`"all"\` for every treatment record or a list of class names for your own; \`labelKey\` only names a line\u2019s therapies (e.g. AGENT), it never decides which records form lines. \`exclude\` drops records by label value, matched ignoring case. More than two cohorts is refused | | \`plot\` | \`x:axis, y:axis, unit?:"sample"\\|"patient", bins?:[240,160], allPoints?:true, points?:[layer(max 4)], colorBy?:axis, ringBy?:axis, cohorts:[names]\` | two axes over the same cases, aggregated **inside the database**: a scatter comes back as counts per grid cell, a box as quartiles per group, two categoricals as a contingency table. The payload is a few kilobytes whatever the cohort's size. A point is a sample, as on cBioPortal's Plots tab; \`unit:"patient"\` makes it a patient, combining a patient's samples: a numeric value is their mean, a category their common value or "Multiple" where they differ, an alteration counts if any sample has it, and the caption says how many patients had more than one sample. Every case is a point up to ${POINTS_CAP_TEXT} (\`allPoints: false\` turns that off), from a participant that may show cases; \`points\` lists up to four subsets drawn in colour over it — \`{rule:"outliers"}\` (the default), \`{rule:"extremes",axis,n}\`, \`{rule:"trend",n}\`, \`{cohort}\` — see **Points** below | @@ -1559,13 +1836,28 @@ patient — so the arm counts summing to more than the base cohort is expected, | \`pathways\` | \`pathways?:[names], sets?:[{name, genes:[≤40]} ×≤4], measure?:"driver"\|"any", topGenes?:6, cohorts:[names] (≤4)\` | **oncogenic signalling pathways**: the ten canonical pathways of Sanchez-Vega et al. (Cell 2018, Table S3: RTK-RAS, PI3K, TP53, Cell cycle, MYC, WNT, NOTCH, Hippo, TGF-β, NRF2 — 334 genes), and/or the document's own \`sets\`, each as the share of the cohort with at least one member altered. \`measure\` defaults to \`"driver"\` (OncoKB-annotated drivers, as the paper counted curated oncogenic events; refused without an annotation pin) or \`"any"\`. A pathway's denominator is the patients profiled for at least ONE member (the union of the members' panels, the oncoprint's merged-row rule, computed in the database with the same event table, CNA rule and driver join as \`compare\`, so a one-gene set equals that gene's own count); each member's % is over the patients profiled for THAT gene. The \`topGenes\` most-altered members are drawn per pathway; the rest are pooled as "+N more altered", and members on no participating panel are listed as never profiled. Two cohorts add Fisher tests with BH q across pathways, and separately across member genes. Counts only; a remote peer cannot answer yet and is named | | \`diffexpr\` | \`profileType, log2?, geneSets?:[≤12 MSigDB names], label?:12, cohorts:[2 names]\` | **differential expression between exactly two cohorts** on one molecular profile (mRNA z-scores, RNA-seq, protein, methylation — refused, listing what the study has, if absent): per gene, Welch's t-test from each cohort's n, Σv and Σv² — additive, so the pooled test is exact across sites and no value is ever sent — with each patient contributing the mean of their samples. \`log2\` compares log2(v+1) and defaults to true for a non-z-score RNA-seq profile; the effect is the difference of means (first cohort minus second) on the compared scale, a log2 fold change under log2. q is Benjamini–Hochberg across every gene tested; genes with fewer than 3 patients in a cohort or no variance are left out and counted. Drawn as a volcano — the 1,500 most significant genes as points, the rest as density, \`label\` genes named, split between the two directions. Then **preranked GSEA** (Subramanian 2005, weight 1) of the unrounded t ranking against \`geneSets\` (default: the 50 MSigDB Hallmark sets; \`[]\` skips it): NES, a permutation p from 1,000 random same-size sets seeded by the set's name (so a shared link reproduces it), BH q across the sets, the leading edge, and the running sum for the strongest set each way. Gene-set permutation ignores gene–gene correlation (anti-conservative next to phenotype permutation, which would need patient-level values) — the caption says so. A remote peer cannot answer yet and is named | | \`paired\` | \`genes?:[≤20], top?:1-40, pairBy?:{attribute, a, b, studies?:{:{attribute, a, b}}, labels?:{a, b}}, measure?:"driver"\|"any", cohorts:[names] (≤3)\` | **paired samples within patients**: among patients with at least one sample on each side of \`pairBy\` (a SAMPLE-level attribute and its values for side a and side b — default \`SAMPLE_TYPE\` Primary / Metastasis; values fold on case and whitespace like every clinical predicate), per gene, the patients with a driver (or, with \`measure:"any"\`, any alteration) on both sides, on side a only, on side b only — counted only among patients profiled for that gene on BOTH sides by each side's own panels, so a gene one side never assayed never reads as discordant. A patient with several samples on a side is altered there if any is. Concordance = both ÷ altered anywhere; an exact McNemar test (two-sided binomial on the discordant patients) asks whether discordance runs one way, BH q across the genes of each cohort. When no patient has both sides, the refusal lists the values the attribute actually takes. Sample type records where tissue came from, not when — the caption says "metastasis-only" is not "acquired later". Counts only; a remote peer cannot answer yet and is named. **Without \`genes\` it scans**: every gene with a driver (or any alteration) in some paired patient is tested, q is Benjamini–Hochberg across all of them, and the \`top\` (default 20) most one-sided are drawn, ordered by McNemar p — how to ask "what is acquired after treatment" without naming genes first. **\`pairBy.studies\`** gives each study its own attribute and values (PRE-TX / POST-TX in one, pre-treatment / post-treatment in another) so their patients pool into one count; a study not listed uses the top-level attribute, and \`labels\` names the two sides in the figure (default: their values). With an attribute other than a sample site, the caption says the sides are as each study labels them, and that a side-only call can also be a subclone one biopsy missed or a lower-purity sample under-calling | +| \`acquired\` | \`anchor?:{eventType, key?, value?}, genes?:[≤30], minPost?:1-1000, top?:1-40, preWindowDays?, minGapDays?:0-3650, sampleEvents?:[types], sampleKey?, measure?:"any"\|"driver", cohorts:[names] (≤3)\` | **alterations acquired under therapy** (preview): patients with a sample dated on or before their FIRST \`anchor\` event (default \`Treatment\`, any; \`key\`/\`value\` such as SUBTYPE = Hormone or AGENT = …, folded like every timeline match) and another at least \`minGapDays\` (default 30) after it. A sample's date is its first timeline event of \`sampleEvents\` (default Sample acquisition / Sequencing / Specimen) naming it in \`sampleKey\` (default SAMPLE_ID); the before-side is the last sample on or before the anchor day (within \`preWindowDays\` when given), the after-side the first past the gap — one sample each. Per gene, counted only where BOTH samples' panels cover it: altered after only (acquired), before only (lost), on both (shared), neither; exact McNemar on acquired vs lost, BH q per cohort. \`measure:"driver"\` counts only OncoKB oncogenic alterations (pinned). Without \`genes\` it scans every gene altered after treatment in at least \`minPost\` (default 3) patients and draws the \`top\` (default 20) smallest p. The result carries the funnel (anchored → a sample before → after → both) and the pairs' spacing in days. Second biopsies are clinically indicated (often at progression), so acquired means seen after and not before, not caused by the treatment — the caption says so. Counts only; a remote peer cannot answer yet and is named. | +| \`ctdna\` | \`genes?:[≤30], top?:1-30, attribute?, plasma?:[values], tissue?:[values], plasmaPanels?:[panels], withinDays?:0-3650, sampleEvents?, sampleKey?, measure?:"any"\|"driver", chFilter?:true\|{genes?, normal?, normalMinAlt?:2, normalMinRatio?:0.3}, cohorts:[names] (≤3)\` | **ctDNA against tissue** (preview): samples are plasma when the sample attribute \`attribute\` (default SAMPLE_CLASS) is one of \`plasma\` (default cfDNA, ctDNA) or the sample is on one of \`plasmaPanels\` (e.g. ACCESS129), tissue when it is one of \`tissue\` (default Tumor). Among patients with both, per gene covered by BOTH kinds' panels: altered in both, tissue only, plasma only, neither (several samples of a kind: altered if any is); positive percent agreement = both ÷ altered in tissue with a 95% Wilson interval, and overall concordance (both + neither) ÷ pairs. Without \`genes\`, the \`top\` (default 15) most altered genes. ctDNA detected = any alteration (with \`measure:"driver"\`, any OncoKB oncogenic one) called in a plasma sample, as a share of the plasma patients with a Wilson interval; overall survival (OS_MONTHS/OS_STATUS as the study records them) by detected vs not, Kaplan–Meier with a log-rank test stratified by study, drawn when each group has at least 5 patients. \`withinDays\` keeps only samples the timeline dates (as the acquired view does) within that many days of a sample of the other kind, and the result says how many were dated. The caption names clonal haematopoiesis behind plasma-only calls and possible immortal time when OS starts before the draw. **\`chFilter\`** leaves out plasma mutation calls likely from clonal haematopoiesis (Razavi et al. 2019) before anything is counted: calls in \`genes\` (default DNMT3A, TET2, ASXL1, PPM1D, JAK2, SF3B1, SRSF2, U2AF1, CBL, GNB1, GNAS, IDH2 — TP53, ATM and CHEK2 are left off because they are tumour drivers as often as CH genes; \`[]\` for none), and (\`normal\`, default on) calls whose matched white-cell sample carries at least \`normalMinAlt\` alternate reads at an allele fraction at least \`normalMinRatio\` × the plasma's, where the study loads normal read counts (tumour DNA that reaches the white-cell sample sits far below that ratio and is kept). Copy-number and structural calls and tissue are never filtered. The result says, per cohort, how many plasma calls and patients each rule removed and in which genes, recomputes agreement, ctDNA detected and survival with the filter, and gives the unfiltered detected share, plasma-only counts and log-rank p beside them. Counts only; a remote peer cannot answer yet. | +| \`exclusivity\` | \`genes:[2-20], stratifyBy?:, measure?:"any"\|"driver", cohorts:[one name]\` | **mutual exclusivity against each tumour's alteration burden** (preview; DISCOVER, Canisius et al. 2016): over the cohort's samples with a gene panel (every sample of each patient), each sample and gene get a background probability σ(a_sample + b_gene), fitted so that every sample's expected number of altered genes is its observed burden and every gene's expected count its observed one — over the whole altered-gene matrix, each sample's own panels deciding which genes it has a probability for (panel-aware; a gene off its panel is not profiled there). Within each study, and within each value of \`stratifyBy\` (e.g. CANCER_TYPE) when given — the confounder DISCOVER exists to remove. For each pair, over the samples profiled for both, the co-altered count is tested against the Poisson-binomial with p_i = p_iA·p_iB: lower tail exclusivity, upper tail co-occurrence, each BH across the pairs; exact, or the refined normal approximation where the exact sum is too large (counted in the caption). The plain two-sided Fisher test on the same samples is reported beside it. More than 20 genes is refused with the limit named. Counts only; a remote peer cannot answer yet. | +| \`instability\` | \`threshold?:0.05-2, armShare?:0.25-0.9, includeX?:bool, cohorts:[names] (≤6)\` | **genomic instability from copy-number segments** (preview; hg19 only — a study on another build is refused, naming which is which): per sample, inside the database, FGA = the share of its segmented length (autosomes; X with \`includeX\`) with \|segment mean\| ≥ \`threshold\` (default 0.2); each arm (p/q split at hg19's centromeres from UCSC cytoBand, the acrocentric p arms of 13, 14, 15, 21, 22 left out: 39 arms) gained or lost when more than \`armShare\` (default 0.5) of its length is past ±threshold, measured when segments cover that share; the aneuploidy score is the number of arms gained or lost (Taylor et al. 2018 style). Only histograms (FGA in 1-point bins × score) and per-arm counts leave the database. Per cohort the quartiles and 5th/95th percentiles of both, Mann–Whitney (2 cohorts) or Kruskal–Wallis (3+) from the histograms, and each arm's gain and loss share of the samples measuring it. Every sample of each patient counts; panel segments are noisy and purity is not corrected — the caption says so. Counts only; a remote peer cannot answer yet. | | \`clonality\` | \`genes:[≤12], measure?:"driver"\|"any", qc?:true, cohorts:[names] (≤3)\` | **clonality from FACETS allele-specific copy number** (only studies that carry it — the refusal names them): per gene, the cohort's mutation CALLS (one mutation in one sample; OncoKB drivers by default) that FACETS called clonal, subclonal or indeterminate, LOH at the locus (minor copy number 0, of calls where it was estimated) and the cancer-cell fraction in ten bins; per cohort, FACETS's own whole-genome-doubling, purity and ploidy calls. \`qc\` (default true) uses only samples whose \`FACETS_QC\` is Pass, with the pass/fail counts reported. Only the study's current mutation profile is read (the table also holds rows orphaned by superseded loads). Two cohorts add Fisher tests on clonal vs subclonal per gene (BH across genes) and on WGD. Counts only; a remote peer cannot answer yet and is named | | \`incidence\` | \`event:{eventType, attrs?:[{key,value} ×≤3], label?}, anchor?:{eventType,key?,value?,occurrence?}, horizons?:[12,24,60], cohorts:[names] (≤6)\` | **cumulative incidence with a competing risk**: time from the anchor (default the study's day 0) to the FIRST \`event\` after it, with death (\`OS_STATUS\`) as the competing event and \`OS_MONTHS\` as follow-up — Aalen–Johansen, with 95% intervals from its delta-method standard error (checked against R's cmprsk: estimates identical to five decimals, SEs within 3%). Not 1 − Kaplan–Meier, which censors the dead as if still at risk and overstates incidence wherever death is common; the view reports both at each of \`horizons\`, so the difference is on the page. Left truncation as in \`survival\`: a patient anchored before day 0 enters the risk set at day 0. Patients who cannot be at risk are counted by reason, never read as event-free: already had the event at or before the anchor, had it before observation began, no follow-up, no anchor event. Two or more cohorts: Gray's test (weight 1) by permutation of cohort labels over the pooled records — exact under the null, seeded, and computable from the (entry, time, cause) tallies each site sends, which are counts | -| \`riskmodel\` | \`cohorts:[one name], endpoint?, monthsAttribute?/statusAttribute?, measure?:"any"\|"driver", minMutated?:30, strata?:"", covariates?:["" ×≤6], anchor?:{eventType,key?,value?,occurrence?}, validate?:false, alpha?:0.5, runs?:10 (≤100), groups?:3 (2-5) | cuts?:[quantiles ×1–4], seed?:1, labelAbove?:0.5, describe?:{attributes?:["" ×≤20], genes?:["" ×≤20]}\` | **a survival risk model from mutated genes**, in the manner of OncoCast (Martin et al.): an elastic-net Cox model (glmnet's algorithm, checked against scikit-survival; Breslow ties; \`alpha\` the l1 share of the penalty) of one cohort's survival on every gene that the panels of EVERY patient cover — so no patient counts as wild-type for a gene never tested — and that is mutated in at least \`minMutated\` patients. Fitted \`runs\` times, each on a random two-thirds of the patients with the penalty chosen by 3-fold cross-validated partial likelihood, scoring the held-out third; a patient's risk score is the mean of their held-out scores, so no one is scored by a model trained on them (a patient never held out has no score and is counted). \`covariates\` are clinical attributes always in the model and never penalised (numbers as they are, categories as indicators against the commonest value, at most 10 values); a patient missing one is left out and counted. \`anchor\` starts each patient's clock at a timeline event, as the survival view does, with the delayed entry that implies (patients without the event, or whose event follows the study's day 0, are counted out). \`validate\` trains only on the discovery half of the engine's fixed split (the \`split\` leaf's halves) and reports everything below on the validation half, which no model saw, cut at the discovery half's quantiles. Reports the held-out C-index across runs (and on the validation half), each gene's share of runs kept and mean hazard ratio, the covariates' hazard ratios, Kaplan-Meier curves of \`groups\` risk groups cut at equal quantiles of the score (no outcome used; a cut moves to the nearer edge of a run of tied scores, so equal scores share a group, and groups merge when the scores cannot fill them) with a log-rank test — or, with \`cuts\` instead of \`groups\` (not both), cut at the quantiles it names, increasing, each strictly between 0 and 1, as OncoCast's \`cuts\` (e.g. \`[0.8]\`, the top fifth against the rest; tied scores still share a group, and the caveat says the cuts were chosen, since cuts placed by looking at the curves overstate the separation — confirm one on the validation half) — each group's 12- and 36-month survival and hazard ratio against the lowest (95% Wald interval), the hazard ratio per standard deviation of the score, and the score's histogram. The score is shown on OncoCast's 0–10 scale as well as raw: (score − min) / (max − min) × 10, min and max the lowest and highest held-out score the cuts are placed on (with \`validate\`, the discovery half's, so a validation patient can fall outside 0–10); the scale is linear, so it changes no group. The genes are also drawn as OncoCast's volcano — mean hazard ratio where kept (log scale) against the share of runs kept, sized by patients mutated — naming those kept in at least \`labelAbove\` of the runs (0–1; drawn in the browser, so changing it re-runs nothing). \`strata\` gives each value its own baseline hazard — use CANCER_TYPE on a mixed cohort, or a gene marking a type carries that type's prognosis. \`describe\` adds **Table 1**, each risk group's makeup, counted on the patients the curves draw (with \`validate\`, the validation half): a categorical attribute's patients per value and group (the patient's value, or their first sample's, case and whitespace folded; those whose samples disagree are counted), with a χ² test; a numeric one's median and IQR per group, with Kruskal–Wallis; a gene's mutated patients over those whose mutation panels cover it (\`measure\` decides what counts as mutated; patients whose panels do not are "not profiled", never unmutated), with a χ² test; Benjamini–Hochberg q-values across rows. Rows the groups differ in by construction are marked — the model's genes (kept in any run) and covariates "in the model", the endpoint's own columns "outcome" — and get a p-value but no q-value, staying out of the correction; the model's stratum is marked "stratum" and stays in, since the score has no stratum term. A list left out takes its default: \`attributes\`, the model's stratum and covariates then the study's attributes in priority order, survival columns left out, keeping the first 8 that have a value here, take more than one, and (categorical) at most 12 values — those passed over are listed with why; \`genes\`, the 15 the model kept in the most runs. A named attribute the study lacks, a categorical one with more than 100 values here, and a gene on none of the patients' panels and mutated in none are set aside by name. \`describe: {}\` is the defaults. One cohort in one patient-level study; at most 300,000 patients × runs per request (refused, naming the limit). Seeded: the same document gives the same model | +| \`cutpoint\` | \`attribute?:"TMB_NONSYNONYMOUS", endpoint?:"OS", monthsAttribute?/statusAttribute?, permutations?:1000 (100–5000), seed?:1, stratifyBy?:"", minStratum?:100, cohorts:[one name]\` | **a biomarker's best survival cut-point, corrected for the search** — **preview** (Samstein 2019, PMID 30643254; PMID 38949888): a numeric marker (the patient's value, or a sample attribute's on the patient's first sample by sample id) against the endpoint. Candidate cuts are the marker's distinct values leaving 10–90% of patients at or below them (at most 100, evenly spaced); at each, the maximally selected standardized log-rank statistic (Lausen & Schumacher; R maxstat's LogRank). The best cut is reported with its permutation-corrected p (\`permutations\` seeded shuffles of the marker; the share whose own best cut does as well) beside the naive log-rank p, the high-vs-low Cox HR, the statistic-against-cut curve (with the permutation 95% line) and Kaplan–Meier curves at the cut. \`stratifyBy\`: a scan per value of a categorical attribute (the largest 8 with ≥ \`minStratum\` patients; the rest counted), as a table. The cut is chosen in-sample and needs validation — the caveat says so. Patient-level studies only | +| \`hla\` | \`resolution?:1\|2\|"full", alleles?:{A?:[first, second]\|[both], B?, C?}, lohAttributes?:[names], lohValues?:[values], endpoint?:"OS", monthsAttribute?/statusAttribute?, covariates?:["" ×≤4], cohorts:[names] (≤3)\` | **HLA class I genotype and HLA LOH** (preview; Chowell et al. 2018, McGranahan et al. 2017): per patient, whether each of HLA-A, -B and -C carries two alleles that match at \`resolution\` fields of the allele name (default 2, protein level: hla_a_02_01_01_01, A*02:01 and A0201 all read A*02:01; 1 = allele group; "full" = every field recorded). The alleles are read from the layouts the public studies use — HLA-A1/HLA-A2 (MSK-IMPACT), HLA_A1_ALLELE(S)/HLA_A2_ALLELES, or one HLA_A attribute holding "A0201/A2402" — or from \`alleles\`. Per cohort: the share of patients typed at all three loci homozygous at 0, 1, 2 or 3, per-locus homozygosity, and the share of patients with an HLA LOH call (\`lohAttributes\`, default HLA_A1_LOH … HLA_C2_LOH and HLA_LOH, any sample) that is a loss (\`lohValues\`, default Loss/Yes/LOH/True). Then the endpoint (default OS) by homozygous at ≥1 locus against heterozygous at all, and by HLA LOH against none: Kaplan–Meier, log-rank and Cox HR (stratified by study when pooled), drawn when each group has ≥ 5 patients; with \`covariates\`, the HR adjusted for them (numbers as they are, categories against the commonest, past 10 levels pooled). A study without HLA alleles is refused, listing its HLA-named attributes. The endpoint runs from the study's own origin — a checkpoint-blockade start only in a study of treated patients that measures from it — and the caption says homozygosity then reads prognosis, not response. Patient-level studies only. | +| \`predictive\` | \`exposure:{eventType,key?,value?}, genes:[≤20], endpoint?:"OS", monthsAttribute?/statusAttribute?, measure?:"any"\|"driver", covariates?:["" ×≤6], minMutated?:5, cohorts:[1–2 names]\` | **predictive biomarkers, gene × treatment** — **preview** (PMIDs 29122777, 32913971, 39499893): the cohorts' patients pooled (baseline hazard stratified by cohort and study when several), \`exposure\` = any timeline event of that type (and key = value), the rest unexposed. Per gene, a Cox model (Efron) of the endpoint on gene + exposed + gene×exposed (+ \`covariates\`, coded as the \`cox\` view codes clinical axes), over the patients whose panel covers the gene — a patient not profiled for it is left out of that fit, never wild-type. Reported per gene: the mutated-vs-wild-type HR among the exposed and among the unexposed (the same model's two contrasts: the interaction coded both ways) and the interaction HR with its Wald p and a Benjamini–Hochberg q over the genes fitted; a gene with fewer than \`minMutated\` mutated patients in either group is listed, not fitted. Drawn as a two-marker forest. Exposure is defined from the whole record (immortal-time bias) and was not randomised (confounding by indication) — the caveat says so. Patient-level studies only | +| \`rmst\` | \`endpoint?:"OS", monthsAttribute?/statusAttribute?, anchor?:{eventType,key?,value?,occurrence?}, tau?:months, landmarkMonths?:[months ×≤4] (default [0]), cohorts:[names] (≤6)\` | **restricted mean survival time and landmark analysis** — **preview**, for when hazards are not proportional (immunotherapy): per cohort the area under the Kaplan–Meier curve from 0 to \`tau\` (the mean months lived — or event-free — within the first τ) with the Greenwood-type standard error of that area (Klein & Moeschberger; R survRM2's rmst1) and a 95% interval; against the first cohort the RMST difference and ratio (log scale), each with a Wald 95% interval and two-sided p. \`tau\` defaults to the largest time at which every cohort still has 10 at risk (stated); a \`tau\` past a cohort's last follow-up is refused. \`landmarkMonths\`: for each landmark L the patients alive and uncensored at L, the clock reset there, KM and RMST from it (τ counted from L), drawn as small multiples. The endpoint is \`OS\` (or \`_MONTHS/_STATUS\`, or \`monthsAttribute\`/\`statusAttribute\`) from day 0; \`anchor\` instead measures OS from a treatment start exactly as \`txendpoints\` anchors (first record, or LAST; OS_MONTHS on the timeline clock, the same clock check; left truncation for an anchor before day 0). KM drawn with the RMST area shaded to τ, and a table. Sites send (entry, time, event) counts only | +| \`multistate\` | \`progression?:{eventType?:"Diagnosis", key?:"PROGRESSION", values?:["Y","Yes"], probabilityKey?, minProbability?}, prevalent?:"include"\|"exclude", horizons?:[12,24,60], cutoffMonths?, cohorts:[names] (≤4)\` | **a multistate (illness–death) model** (Rueda et al., Nature 2019, PMID 30867590) — **preview**: states start (the timeline's day 0 — first sequencing in MSK-CHORD; the view checks how many patients have a Sequencing event on day 0 and says what day 0 is), progression (the first \`progression\` event after day 0, as \`txendpoints\` reads it: values folded, an optional probability floor) and death (OS_STATUS / OS_MONTHS on the timeline's clock, with \`txendpoints\`' clock check). A patient with a progression on or before day 0 starts in the progression state (\`prevalent:"exclude"\` leaves them out); a progression on or after the end of follow-up is not counted. Markov (clock-forward): every hazard runs on time since day 0, progression → death with delayed entry at the progression. Per cohort: each transition's events, person-months and rate per 100 person-months (Poisson 95% interval), Nelson–Aalen cumulative hazards, Aalen–Johansen state occupation drawn as stacked areas (to where 10 patients remain followed) and tabled at \`horizons\`; per transition a Cox model (Efron; delayed entry for progression → death; a baseline per study) of each cohort against the first, no ratio for a cohort with under 5 events. Sites send (prevalent, progression time, end time, dead) counts only. Progression from radiology NLP is probabilistic and unseen in patients never imaged — the caption counts them | +| \`txendpoints\` | \`anchor:{eventType,key?,value?,occurrence?}, endpoint?:"ttnt"\|"tot"\|"rwpfs", lineGap?:90, lineClasses?:"systemic"\|"all"\|[classes], progression?:{eventType?:"Diagnosis", key?:"PROGRESSION", values?:["Y","Yes"], probabilityKey?, minProbability?}, ongoingDays?:30, cutoffMonths?, cohorts:[names] (≤6)\` | **real-world treatment endpoints** (MSK-CHORD, Nature 2024; GENIE BPC) — **preview**: the clock starts at the anchored treatment record's start (first, or \`occurrence:"LAST"\`), and runs to \`ttnt\` the start of the next treatment LINE after the anchor's (lines as the \`sequence\` view builds them — \`lineGap\`, \`lineClasses\`), \`tot\` the latest stop of the anchored records in that line (a living patient stopping within \`ongoingDays\` of last follow-up is censored as still on it; a record without a stop date is left out and counted), or \`rwpfs\` the first \`progression\` event after it (values folded; an optional probability floor) — or death, an event for every endpoint; censored at last follow-up, \`OS_MONTHS\` × 30.4375 days on the timeline clock (checked per study: a study whose timeline runs more than 31 days past OS_MONTHS for over 5% of anchored patients is refused). Left truncation as in \`survival\`. Kaplan–Meier per cohort with the number at risk, medians with 95% intervals (Greenwood, log scale), and a k-group log-rank (stratified by study). Sites send (entry, time, event) counts only. Treated status is not randomised: the caption says so | +| \`riskmodel\` | \`cohorts:[one name], endpoint?, monthsAttribute?/statusAttribute?, measure?:"any"\|"driver", minMutated?:30, strata?:"", covariates?:["" ×≤6], anchor?:{eventType,key?,value?,occurrence?}, validate?:false, alpha?:0.5, runs?:10 (≤100), groups?:3 (2-5) | cuts?:[quantiles ×1–4], seed?:1, labelAbove?:0.5, describe?:{attributes?:["" ×≤20], genes?:["" ×≤20]}, validateOn?:""\` | **a survival risk model from mutated genes**, in the manner of OncoCast (Martin et al.): an elastic-net Cox model (glmnet's algorithm, checked against scikit-survival; Breslow ties; \`alpha\` the l1 share of the penalty) of one cohort's survival on every gene that the panels of EVERY patient cover — so no patient counts as wild-type for a gene never tested — and that is mutated in at least \`minMutated\` patients. Fitted \`runs\` times, each on a random two-thirds of the patients with the penalty chosen by 3-fold cross-validated partial likelihood, scoring the held-out third; a patient's risk score is the mean of their held-out scores, so no one is scored by a model trained on them (a patient never held out has no score and is counted). \`covariates\` are clinical attributes always in the model and never penalised (numbers as they are, categories as indicators against the commonest value, at most 10 values); a patient missing one is left out and counted. \`anchor\` starts each patient's clock at a timeline event, as the survival view does, with the delayed entry that implies (patients without the event, or whose event follows the study's day 0, are counted out). \`validate\` trains only on the discovery half of the engine's fixed split (the \`split\` leaf's halves) and reports everything below on the validation half, which no model saw, cut at the discovery half's quantiles. Reports the held-out C-index across runs (and on the validation half), each gene's share of runs kept and mean hazard ratio, the covariates' hazard ratios, Kaplan-Meier curves of \`groups\` risk groups cut at equal quantiles of the score (no outcome used; a cut moves to the nearer edge of a run of tied scores, so equal scores share a group, and groups merge when the scores cannot fill them) with a log-rank test — or, with \`cuts\` instead of \`groups\` (not both), cut at the quantiles it names, increasing, each strictly between 0 and 1, as OncoCast's \`cuts\` (e.g. \`[0.8]\`, the top fifth against the rest; tied scores still share a group, and the caveat says the cuts were chosen, since cuts placed by looking at the curves overstate the separation — confirm one on the validation half) — each group's 12- and 36-month survival and hazard ratio against the lowest (95% Wald interval), the hazard ratio per standard deviation of the score, and the score's histogram. The score is shown on OncoCast's 0–10 scale as well as raw: (score − min) / (max − min) × 10, min and max the lowest and highest held-out score the cuts are placed on (with \`validate\`, the discovery half's, so a validation patient can fall outside 0–10); the scale is linear, so it changes no group. The genes are also drawn as OncoCast's volcano — mean hazard ratio where kept (log scale) against the share of runs kept, sized by patients mutated — naming those kept in at least \`labelAbove\` of the runs (0–1; drawn in the browser, so changing it re-runs nothing). \`strata\` gives each value its own baseline hazard — use CANCER_TYPE on a mixed cohort, or a gene marking a type carries that type's prognosis. \`describe\` adds **Table 1**, each risk group's makeup, counted on the patients the curves draw (with \`validate\`, the validation half): a categorical attribute's patients per value and group (the patient's value, or their first sample's, case and whitespace folded; those whose samples disagree are counted), with a χ² test; a numeric one's median and IQR per group, with Kruskal–Wallis; a gene's mutated patients over those whose mutation panels cover it (\`measure\` decides what counts as mutated; patients whose panels do not are "not profiled", never unmutated), with a χ² test; Benjamini–Hochberg q-values across rows. Rows the groups differ in by construction are marked — the model's genes (kept in any run) and covariates "in the model", the endpoint's own columns "outcome" — and get a p-value but no q-value, staying out of the correction; the model's stratum is marked "stratum" and stays in, since the score has no stratum term. A list left out takes its default: \`attributes\`, the model's stratum and covariates then the study's attributes in priority order, survival columns left out, keeping the first 8 that have a value here, take more than one, and (categorical) at most 12 values — those passed over are listed with why; \`genes\`, the 15 the model kept in the most runs. A named attribute the study lacks, a categorical one with more than 100 values here, and a gene on none of the patients' panels and mutated in none are set aside by name. \`describe: {}\` is the defaults. One cohort in one patient-level study; at most 300,000 patients × runs per request (refused, naming the limit). Seeded: the same document gives the same model. \`validateOn\` (preview; PMIDs 37477937, 38488807): external validation on another cohort of the document (one patient-level study, any): its patients are scored with the ensemble's mean coefficients (each gene's coefficient averaged over every run, zero where a run dropped it), cut at the training risk-group thresholds, and reported with Harrell's C-index (within strata; seeded bootstrap 95% interval, 200 resamples), Kaplan–Meier curves by group, the log-rank test and each group's HR against the lowest — drawn below the model's figure. A validation patient whose panels miss a gene the model weighs is left out and counted, never scored as wild-type; a model with \`covariates\` is refused (they are not carried yet). The fit itself is unchanged, and the result without \`validateOn\` is identical | | \`labs\` | \`test, anchor:{eventType,key?,value?,occurrence?}, eventType?:"Lab_Test", testKey?:"TEST", resultKey?:"RESULT", baselineDays?:90, fromWeeks?:-12, toWeeks?:52, binWeeks?:4, responseDrop?:50, cohorts:[names] (≤4)\` | **a lab value's course from a treatment start**: each patient's \`test\` results (timeline events of \`eventType\` whose \`testKey\` names the test, value in \`resultKey\`) as % change from their own baseline — the last value in the \`baselineDays\` up to and including the anchor; a non-positive baseline is left out — per \`binWeeks\` bin from \`fromWeeks\` to \`toWeeks\`, drawn as median and interquartile band (from 2-point histograms, exact to within a point; a patient counts once per bin, at their mean; the baseline reading itself is not drawn). Best response is each patient's lowest value after the anchor; the share falling by at least \`responseDrop\`% is counted exactly (50 on PSA is PCWG3's PSA50), and two cohorts get a Fisher test on it. A **waterfall** of every patient's best change is drawn only from participants entitled to patient-level values (a method only PatientLevelResolver has, like a plot's points); an aggregate-only participant's patients stay in the histograms and the rate and are counted as withheld from the bars. With no evaluable patient, the refusal lists the tests the study records | | \`cancertypes\` | \`genes:[≤30], groupBy?:"CANCER_TYPE"\|\|"study", measure?:"any"\|"driver", minSamples?:10, show?:"frequency"\|"count", unit?:"patient"\|"sample", cohorts:[names] (≤2)\` | **Cancer Types Summary**, cBioPortal's results-view tab: per group of the cohort's PATIENTS (the portal's default; \`unit:"sample"\` counts samples — a patient counts in each group one of their samples is in) — a sample attribute's value (folded on case and whitespace like every clinical predicate; samples with no value form their own group) or the study — the share altered in at least one of \`genes\`, split by kind: only mutations, only amplifications, only deep deletions, only structural variants, or more than one kind. A group's denominator is its samples profiled for at least one of the genes (the union of their panels, as an oncoprint's merged row counts it); its total sample count is shown beside it. \`measure:"driver"\` counts OncoKB drivers only. Groups with fewer than \`minSamples\` profiled samples are pooled into one bar, named and counted, never dropped. Counts only, so every local participant can answer; a remote peer cannot yet and is named | | \`overlap\` | \`cohorts:[names] (2–6)\` | **how cohorts' patients intersect**, cBioPortal's group-comparison Overlap tab: for every combination of the cohorts, the patients — and their samples — in exactly those cohorts and none of the others, with each cohort's own size. Two or three cohorts are drawn as the portal draws them, area-proportional diagrams of samples and of patients side by side; more as an UpSet plot. A cohort is a set of patients, so a patient's samples go wherever the patient does. Each study's database answers with one count per combination (a bitmask of membership) — counts only — and a cohort's size here must equal its own count, or the view says the definition gave two answers. Ids are study-qualified, so patients only ever overlap within a study | | \`enrichment\` | \`alteration?:"mutation"\|"cna"\|"amp"\|"homdel"\|"sv", measure?:"any"\|"driver", minAltered?:5, label?:12, cohorts:[2–6 names]\` | **genomic-alteration enrichment across every gene**, cBioPortal's group-comparison Genomic Alterations tab: each gene's frequency of one alteration type per cohort, counted in SAMPLES over the samples profiled for that gene — samples on a panel listing it (counted per panel, then joined to its genes, Explore's own construction) plus whole-exome samples, and a sample altered off-panel counts as measured. Uncalled mutations are excluded as the portal excludes them; copy number is high-level only. Two cohorts: a two-sided Fisher test and the log2 ratio of the altered shares, drawn as a volcano beside the table; three or more: a chi-squared test. q is Benjamini–Hochberg across every gene tested; genes altered in fewer than \`minAltered\` samples, or unprofiled in a cohort, are counted as not tested. The caption warns that hypermutated tumours enrich almost every gene. Counts only | +| \`actionability\` | \`groupBy?:"", top?:8, pairBy?:{attribute, a:[values], b:[values]}\|null (default SAMPLE_TYPE Primary / Metastasis), cohorts:[names] (≤4)\` | **clinical actionability from the version-pinned OncoKB store**: each patient with a sample sequenced for mutations, at their highest sensitive therapeutic level (Level 1, 2, 3A, 3B, 4, or none) over the called mutations and high-level copy-number calls in any of their samples (structural variants too where the store levels them; the caveat says which types it holds), and separately whether any alteration carries a resistance level (R1, R2). The store's level is its highest for the alteration in any cancer type, not in the patient's own, so the shares are an upper bound — stated in the caveat. Per cohort, and with \`groupBy\` per group of that attribute per patient ("Multiple" when samples disagree; the \`top\` largest drawn, the rest pooled into a named Other). For patients with a sequenced sample on each side of \`pairBy\` (values matched case-insensitively), the 6×6 cross-table of their highest level on each side and the discordant share. "No level" means none on the genes sequenced — an unprofiled gene is unknown. Counts only, computed in each local database. | +| \`drivernegative\` | \`drivers?:["" ×≤40] (default EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1, NTRK2, NTRK3), alteration?:"mutation"\|"sv"\|"cna", measure?:"any"\|"driver", minAltered?:5, label?:12, cohorts:[1 name]\` | **driver-negative analysis within one cohort**: each patient sequenced for mutations is driver-positive with an OncoKB driver (oncogenic or likely oncogenic, at the pin: a called mutation, a high-level copy-number call or a structural variant) in one of \`drivers\`; else driver-negative when a mutation panel of theirs covers every listed gene (or whole-exome); else "not fully profiled" — counted, in neither group, never driver-negative. Then every other gene's \`alteration\` (default mutation; \`sv\` counts either fusion partner) frequency is compared between the two groups in PATIENTS over the patients profiled for that gene (panel-aware as \`enrichment\` counts, an off-panel call counting as measured): two-sided Fisher, Benjamini–Hochberg q, log2 of the driver-negative share over the driver-positive one. Drawn as the group split and a volcano, with the genes most enriched in the driver-negative group. Counts only. | +| \`organotropism\` | \`source?:"auto"\|"flags"\|"timeline"\|"sample", flagPrefix?:"DMETS_DX_", timeline?:{eventType?:"Diagnosis", key?:"TUMOR_SITE"}, siteAttribute?:"METASTATIC_SITE", sites?:[labels], genes?:[≤20] \| top?:12, alteration?:"mutation"\|"cna"\|"amp"\|"homdel"\|"sv", measure?:"driver"\|"any", stratifyBy?:"CANCER_TYPE"\|null, minPatients?:5, incidence?:{site?, gene?}\|null, cohorts:[1 name]\` | **metastatic organotropism**: which genes' alterations go with metastasis to which organ (MSK-MET, Nguyen et al. Cell 2022). Sites come from per-organ Yes/No flags (\`flagPrefix\`), from the timeline (event type and key naming a site, at any date) or from the biopsy site of metastasis samples (free text mapped to organs by the view's disclosed rules, the rest as a listed "Other"); \`auto\` takes the first the cohort has and the view refuses, naming what exists, when none is. Per site and gene, the patients evaluable for the site and profiled for the gene (panel-aware: an unprofiled patient is in neither group) form a 2×2 per stratum of \`stratifyBy\` (per patient); Cochran–Mantel–Haenszel test and Mantel–Haenszel odds ratio, Benjamini–Hochberg over the grid; a cell needs \`minPatients\` altered, with and without the site. Genes: a list, or the \`top\` most altered. Drawn as a gene × site heatmap of log2 OR (outlined at q < 0.05) and the strongest cells. With the timeline source, also the cumulative incidence of the first event at one site after day 0 by one gene's status (default the grid's most significant cell), death competing (Aalen–Johansen, Gray's permutation test); patients with the site by day 0 are excluded, and the panel is withheld when more than 5% of records have the site after their last follow-up. A site is only what imaging, biopsy or abstraction recorded. Counts only. | +| \`molsubtype\` | \`classifier?:"endometrial", msi?:{attribute?, high?:[values], stable?:[values]} \| {attribute, threshold, stableBelow?} (default MSI_TYPE Instable / Stable, else MSI_SCORE or MSI_SENSOR_SCORE ≥ 10 / < 3), p53?:"driver"\|"nonsynonymous", by?:"", top?:8, survival?:true, cohorts:[≤6 names]\` | **a published molecular classifier as derived subtypes**, so far the endometrial carcinoma classification (TCGA 2013 / ProMisE / WHO 2020): per patient over all their samples, first match wins — POLE-ultramutated (a somatic POLE exonuclease-domain hotspot: P286R, S297F, F367S/V, V411L, L424I/V, P436R, M444K, A456P, S459F, M295R, D368Y; León-Castillo et al. 2020), else MMR-deficient (MSI-high by the \`msi\` rule), else p53-abnormal (an OncoKB-driver TP53 mutation, or with \`p53:"nonsynonymous"\` any protein-changing one), else NSMP. A patient missing an input the hierarchy needs before reaching their subtype — not sequenced, not profiled for POLE or TP53 (panel-aware), no MSI-high or stable call — is "not classifiable", counted and kept out of shares, tests and curves; never guessed. Per cohort: subtype shares (a stacked bar; chi-squared between cohorts), with \`by\` one bar per group (the \`top\` largest, the rest pooled into a named Other; chi-squared), and overall survival by subtype (Kaplan–Meier, k-group log-rank) for the first three cohorts. Another \`classifier\` is refused, naming those that exist. Counts only. | +| \`germline\` | \`genes?:[≤60] (default every gene with ≥ minCarriers carriers), minCarriers?:3, survival?:{gene}, cohorts:[≤4 names]\` | **germline carriers and their somatic second hits**, per patient: carriers are patients with a mutation the study marks GERMLINE in the gene, among the patients sequenced for it (a tumour mutation panel listing it, or whole-exome; a carrier whose panel omits the gene is counted apart — a gene not on a patient's panel never makes them a non-carrier). The carrier share is of patients sequenced, not germline-tested, since the warehouse cannot tell a negative germline test from none. Among carriers, a somatic second hit in the same gene: another called mutation not marked germline, or a deep deletion (−2, among patients profiled for copy number); loss of heterozygosity is not assessed (no allele-specific copy number is read; the result says when the study has FACETS), so the rate is a floor. Compared with the same somatic rate in non-carriers: Fisher per gene, Benjamini–Hochberg over the cohort's genes. With \`survival\`, overall survival of that gene's carriers against non-carriers (Kaplan–Meier, log-rank). Counts only. | | \`assays\` | \`profileType:"", minN?:3, label?:12, cohorts:[2–6 names]\` | **every entity of one generic-assay profile compared between cohorts**, cBioPortal's group-comparison tabs for generic assays (mutational signatures, arm-level copy number, treatment response, …): one value per sample (every sample of each cohort's patients) or per patient for a patient-level profile. A numeric profile: each entity's n, Σv and Σv² per cohort — values that are not numbers, such as a limit \`">8"\` or NA, are counted and left out — then Welch's t for two cohorts (drawn as a volcano of the difference in means) or Welch's ANOVA for more. A categorical one: each entity's count per value per cohort — NA and blank are not called and are counted apart — then a chi-squared test on cohort × value. q is Benjamini–Hochberg across every entity tested; an entity with fewer than \`minN\` values in a cohort, or with no spread, is counted as not tested. Per-probe methylation is refused (hundreds of thousands of probes), as is any profile past 20,000 entities. Counts and moments only | | \`composition\` | \`profileType:"", entities?:[ids] | attributes:["", …2–16], unit?:"patient"|"sample", normalize?:true, sort?:"", cohorts:[1–8 names]\` | **what each case is made of, as parts of a whole, compared between cohorts** — the stacked-bar panels of single-cell studies (cell types, cell states) and of signature studies. The parts are a generic-assay profile's entities (per sample, or per patient for a patient-level profile; at most 16 — choose them with \`entities\`) or 2–16 numeric clinical attributes, \`custom:\` uploads included (per patient unless \`unit:\"sample\"\`). A case with every part, none below zero and a sum above zero is kept, its parts divided by their sum unless \`normalize:false\`; the rest are counted as left out. Inside the database, per cohort: n, Σp and Σp² per part and Σ, Σ² of each case's Shannon entropy. Each cohort against the view's other cohorts pooled, part by part: log2 of the ratio of means (each + 0.001) and Welch's t, Benjamini–Hochberg across the comparisons (with two cohorts, once per part). The figure draws each cohort's cases as stacked bars ordered by \`sort\` (default the first part), beside the cohort's mean, and a cohort × part matrix; the bars come only from participants that may show cases, and past 2,000 cases a cohort is drawn as its mean alone, saying so. Sums only, apart from those bars, which carry no ids | | \`mutations\` | \`gene:"", columns?, search?, filters?, sort?:{column, desc?}, rows?:25, cohorts:[≤6 names]\` | **one gene's mutation table**, the table under cBioPortal's Mutations-tab lollipop: every called mutation of \`gene\` in each cohort's samples (uncalled calls left out), one row per call — sample, detailed cancer type, protein change, the OncoKB annotation at the pinned version, mutation and variant type, allele frequency (variant / variant + reference reads) with both read counts, the study's MUTATION_COUNT, chromosome, positions, alleles, mutation status. Column ids: \`sample\`, \`cancerType\`, \`proteinChange\`, \`annotation\`, \`mutationType\`, \`variantType\`, \`vaf\`, \`altCount\`, \`refCount\`, \`mutCount\`, \`chr\`, \`start\`, \`end\`, \`ref\`, \`var\`, \`status\`; shown by default: sample, cancerType, proteinChange, annotation, mutationType, mutCount (the portal's own). Searched, filtered and sorted as every table view is (see *Table views* below; default order drivers first, then by codon; \`proteinChange\` sorts by codon, \`annotation\` drivers first). Its rows name samples, so only a patient-level participant contributes them; any other study is named | @@ -1931,6 +2223,10 @@ export const AGENT_GRAMMAR = `Document shape: {"kind":"survival","endpoint":"OS|PFS|DFS|…","anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Chemo","occurrence":"FIRST"},"cohorts":[names?]}, {"kind":"cox","endpoint":"OS|PFS|DFS|...","covariates":[axis, ...(max 8)],"stratifyByStudy":true?,"stratifyByCohort":true?,"cutoffMonths":number?,"cohorts":[names?]}, {"kind":"logistic","outcome":,"outcomeEvent":""?,"covariates":[axis, ...(max 8)],"cohorts":[names?]}, + {"kind":"forest","exposure":,"exposed":""?,"threshold":number?,"strata":"CANCER_TYPE||study|cohort","covariates":[axis, ...(max 4)]?,"minPatients":30,"minEvents":10,"cohorts":[names?]}, + // one exposure's HR per stratum (Cox), pooled by DerSimonian–Laird random effects + fixed effect; I², Q (patient-level) + {"kind":"adjusted","covariates":["MSI_TYPE","TMB_NONSYNONYMOUS"]?,"measure":"any|driver","minShare":0.05,"minAltered":10,"cohorts":[2 names]}, + // every gene mutated in ≥minShare: logistic mutated ~ cohort + covariates, adjusted OR beside raw OR/Fisher, BH q (patient-level) // outcome is a BINARY axis: a categorical one (clinical or alteration) needs outcomeEvent naming // which observed value is the event; a numeric one must already be 0/1 and must NOT set outcomeEvent. // Covariates use the exact same dummy-coding/reference rules as cox above. @@ -1973,24 +2269,58 @@ export const AGENT_GRAMMAR = `Document shape: "measure":"driver|any","cohorts":[names?(max 3)]}, // patients with both kinds of sample: per gene, altered on both / a only / b only; McNemar test. // no genes = scan every altered gene, draw the top most one-sided; studies = per-study sides, pooled + {"kind":"acquired","anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Hormone"}?,"genes":[...(max 30)]?,"minPost":3,"top":20, + "preWindowDays":365?,"minGapDays":30,"measure":"any|driver","cohorts":[names?(max 3)]}, + // (preview) last sample before the first anchor vs first sample >= minGapDays after: per gene acquired / lost / shared; McNemar + {"kind":"ctdna","genes":[...(max 30)]?,"top":15,"attribute":"SAMPLE_CLASS","plasma":["cfDNA","ctDNA"],"tissue":["Tumor"],"plasmaPanels":["ACCESS129"]?, + "withinDays":90?,"measure":"any|driver","chFilter":true?,"cohorts":[names?(max 3)]}, + // (preview) plasma vs tissue per gene (both / tissue-only / plasma-only, PPA + Wilson CI); % ctDNA detected; OS KM by detected + // chFilter: drop plasma mutation calls in CH genes or seen in the matched white-cell sample, with before/after + {"kind":"exclusivity","genes":[...(2-20)],"stratifyBy":"CANCER_TYPE"?,"measure":"any|driver","cohorts":[one name]}, + // (preview) DISCOVER: burden-aware background, Poisson-binomial tails per pair (exclusive / co-occurring), Fisher beside it + {"kind":"instability","threshold":0.2,"armShare":0.5,"includeX":false,"cohorts":[names?(max 6)]}, + // (preview) per sample from hg19 segments: FGA, arm gains/losses, aneuploidy score; boxes + rank test, arm x cohort heatmap {"kind":"clonality","genes":[...(max 12)],"measure":"driver|any","qc":true,"cohorts":[names?(max 3)]}, // FACETS: % clonal / subclonal, LOH at the locus per gene; WGD, purity, ploidy per cohort {"kind":"incidence","event":{"eventType":"Diagnosis","attrs":[{"key":"TUMOR_SITE","value":"CNS/Brain"}],"label":"brain metastasis"}, "anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Chemo"}?,"horizons":[12,24,60],"cohorts":[names?(max 6)]}, // cumulative incidence of the first event, death competing (Aalen-Johansen, not 1-KM); Gray's test + {"kind":"rmst","endpoint":"OS"?,"anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Immuno"}?,"tau":24?,"landmarkMonths":[0,6,12]?,"cohorts":[names?(max 6)]}, + // restricted mean survival time to tau per cohort, difference and ratio vs the first; landmark small multiples (preview) + {"kind":"multistate","progression":{"eventType":"Diagnosis","key":"PROGRESSION","values":["Y","Yes"]}?,"prevalent":"include|exclude"?,"horizons":[12,24,60]?,"cohorts":[names?(max 4)]}, + // illness-death model from day 0: state occupation (Aalen-Johansen), cumulative hazards, rates and a Cox HR per transition (preview) + {"kind":"txendpoints","anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Immuno"},"endpoint":"ttnt|tot|rwpfs","lineGap":90, + "progression":{"eventType":"Diagnosis","key":"PROGRESSION","values":["Y","Yes"]}?,"cohorts":[names?(max 6)]}, + // real-world endpoints from a treatment start: next line / end of treatment / progression, or death; KM + log-rank (preview) + {"kind":"predictive","exposure":{"eventType":"Treatment","key":"SUBTYPE","value":"Immuno"},"genes":[...(max 20)],"endpoint":"OS","covariates":[""...(max 6)]?,"minMutated":5,"cohorts":[1-2 names]}, + // per gene: Cox on gene + exposed + gene x exposed; HR among exposed / unexposed, interaction p, BH q (patient-level; preview) + {"kind":"hla","cohorts":[names?(max 3)],"resolution":2,"alleles":{"A":["HLA-A1","HLA-A2"]}?,"lohAttributes":["HLA_A1_LOH"]?,"endpoint":"OS","covariates":["TMB_SCORE"]?}, + // (preview) % homozygous at 0/1/2+ class I loci, % HLA LOH; OS KM + Cox by homozygosity and by LOH + {"kind":"cutpoint","cohorts":[one name],"attribute":"TMB_NONSYNONYMOUS","endpoint":"OS","permutations":1000,"seed":1,"stratifyBy":"CANCER_TYPE"?,"minStratum":100}, + // best cut of a numeric marker for survival (max log-rank between p10 and p90), permutation-corrected p, KM at the cut (patient-level; preview) {"kind":"labs","test":"PSA","anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Hormone"}, "baselineDays":90,"fromWeeks":-12,"toWeeks":52,"binWeeks":4,"responseDrop":50,"cohorts":[names?(max 4)]}, // % change from baseline over time (median/IQR), best response, responders (PSA50), waterfall {"kind":"riskmodel","cohorts":[one name],"endpoint":"OS","measure":"any|driver","strata":""?,"covariates":[""...(max 6)]?, "anchor":{"eventType":"Treatment","key":"SUBTYPE","value":"Immuno"}?,"validate":true?,"alpha":0.5,"runs":10,"groups":3 | "cuts":[0.8],"minMutated":30,"seed":1,"labelAbove":0.5?, - "describe":{"attributes":[""...(max 20)]?,"genes":[""...(max 20)]?}?}, - // OncoCast-style elastic-net Cox ensemble on mutated genes: held-out C-index, genes kept (and their volcano), KM by risk group, score on 0–10, and with describe each group's makeup (Table 1; model rows marked, no q) (patient-level, one study) + "describe":{"attributes":[""...(max 20)]?,"genes":[""...(max 20)]?}?,"validateOn":""?}, + // OncoCast-style elastic-net Cox ensemble on mutated genes: held-out C-index, genes kept (and their volcano), KM by risk group, score on 0–10, and with describe each group's makeup (Table 1; model rows marked, no q) (patient-level, one study); validateOn scores another cohort at the training cuts (C-index + bootstrap CI, KM, log-rank, HRs) {"kind":"cancertypes","genes":[...(max 30)],"groupBy":"CANCER_TYPE||study","measure":"any|driver","minSamples":10,"show":"frequency|count","unit":"patient|sample"}, // the portal's Cancer Types Summary: % of samples altered per cancer type, split by mutation/amp/deep del/SV/multiple {"kind":"overlap","cohorts":[names?(2-6)]}, // UpSet plot: patients in exactly each combination of cohorts {"kind":"enrichment","alteration":"mutation|cna|amp|homdel|sv","measure":"any|driver","minAltered":5,"cohorts":[2-6 names]}, // every gene's alteration frequency compared between cohorts (Fisher/chi-squared, BH), volcano + table + {"kind":"actionability","groupBy":"CANCER_TYPE"?,"top":8,"pairBy":{"attribute":"SAMPLE_TYPE","a":["Primary"],"b":["Metastasis"]}|null,"cohorts":[names?(max 4)]}, + // each patient's highest OncoKB level (1–4, none) and resistance (R1/R2), stacked per cohort/group; primary vs metastasis concordance + {"kind":"drivernegative","drivers":["EGFR","KRAS","ALK",...]?,"alteration":"mutation|sv|cna","measure":"any|driver","minAltered":5,"cohorts":[one name]}, + // driver-positive vs driver-negative (panel-aware; not fully profiled kept apart), every other gene compared (Fisher, BH) + {"kind":"organotropism","source":"auto|flags|timeline|sample","genes":[≤20]?,"top":12,"measure":"driver|any","stratifyBy":"CANCER_TYPE"|null,"incidence":{"site":"","gene":""}?,"cohorts":[one name]}, + // gene × metastatic-site heatmap (CMH within cancer types, BH); timeline sites: cumulative incidence of one site by one gene, death competing + {"kind":"molsubtype","classifier":"endometrial","msi":{"attribute":"MSI_TYPE","high":["Instable"],"stable":["Stable"]}|{"attribute":"MSI_SCORE","threshold":10,"stableBelow":3}?,"p53":"driver|nonsynonymous","by":""?,"survival":true,"cohorts":[≤6 names]}, + // endometrial molecular subtypes per patient (POLEmut > MMRd > p53abn > NSMP; not classifiable kept apart), shares, by an attribute, OS by subtype + {"kind":"germline","genes":[≤60]?,"minCarriers":3,"survival":{"gene":""}?,"cohorts":[≤4 names]}, + // germline carriers per gene among patients sequenced, carriers' somatic second hit (mutation or deep deletion) vs non-carriers (Fisher, BH); OS by carrier status {"kind":"assays","profileType":"","minN":3,"cohorts":[2-6 names]}, // every entity of a generic-assay profile (signatures, arm-level CNA, …) between cohorts (Welch t/ANOVA or chi-squared, BH) {"kind":"composition","profileType":""|"attributes":["",…2-16],"sort":""?,"normalize":true?,"cohorts":[1-8 names]}, diff --git a/lib/federation-views.ts b/lib/federation-views.ts index 60f62e4..9172677 100644 --- a/lib/federation-views.ts +++ b/lib/federation-views.ts @@ -5,11 +5,13 @@ // participant, aggregate-only included, can answer. import { sq } from "./compiler.ts"; import { ALTERED_COND, driverJoin } from "./federation.ts"; -import { clinicalValueFoldSql, nameFoldSql, foldedEqualsSql } from "./event-names.ts"; +import { clinicalValueFoldSql, nameFoldSql, foldedEqualsSql, foldedInSql } from "./event-names.ts"; import { clinicalTable } from "./custom-data.ts"; import { splitBucketSql } from "./cohort2.ts"; import { casesCte, type CompositionSource } from "./composition.ts"; import { finiteSql } from "./sql-number.ts"; +import { linesCtes, type LineSpec } from "./treatment-lines.ts"; +import { VIEW_ATTR_RE } from "./custom-data-names.ts"; /** bare patient id for a *_unique_id column of this study */ const pidOf = (col: string, study: string): string => "replaceOne(" + col + ", " + sq(study + "_") + ", '')"; @@ -238,11 +240,19 @@ function pairedSidesCtes(cohortSql: string, study: string, db: string, by: PairB */ export function pairedCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, by: PairBy, pin: string | null, measure: "driver" | "any"): string { + return pairedSidesCtes(cohortSql, study, db, by) + ",\n" + sideCountsTail(study, db, genes, pin, measure); +} + +/** + * The per-gene counting after a `ps` CTE (suid, pid, side 'a' | 'b') — shared by every view that + * puts each patient's samples on two sides: the paired view's sample attribute, the acquired + * view's samples before and after a treatment start. + */ +function sideCountsTail(study: string, db: string, genes: string[] | null, pin: string | null, measure: "driver" | "any", exclude?: string): string { const scan = genes == null; const geneList = scan ? "SELECT DISTINCT gene FROM alt" : genes.map(sq).join(","); const hit = measure === "driver" ? "max(ann.is_driver)" : "1"; return ( - pairedSidesCtes(cohortSql, study, db, by) + ",\n" + "alt AS (\n" + " SELECT g.hugo_gene_symbol AS gene, ps.pid AS pid, ps.side AS side, " + (pin || measure === "any" ? hit : "0") + " AS hit\n" + " FROM " + db + ".genomic_event_derived g\n" + @@ -250,7 +260,7 @@ export function pairedCountsSql(cohortSql: string, study: string, db: string, ge " INNER JOIN ps ON ps.suid = g.sample_unique_id\n" + " WHERE g.cancer_study_identifier = " + sq(study) + (scan ? "" : " AND g.hugo_gene_symbol IN (" + geneList + ")") + " AND " + ALTERED_COND + "\n" + // narrows the events before the annotation join rather than after it - " AND g.sample_unique_id IN (SELECT suid FROM ps)\n" + + " AND g.sample_unique_id IN (SELECT suid FROM ps)" + (exclude ? " AND " + exclude : "") + "\n" + " GROUP BY gene, pid, side\n" + (scan ? " HAVING hit > 0\n" : "") + "),\n" + @@ -292,6 +302,437 @@ export function sampleAttributeValuesSql(cohortSql: string, study: string, db: s ); } +// --------------------------------------------------------------------------- +// acquired: each patient's last sample before a treatment start and first sample after it + +export interface AcquiredSides { + /** the treatment start: its FIRST event of this type (with key = value, when given) */ + anchor: { eventType: string; key?: string; value?: string }; + /** the timeline event types that date a sample, and the attribute naming the sample */ + sampleEvents: string[]; sampleKey: string; + /** pre: the last dated sample on or before the anchor day, at most this many days before it (null: any) */ + preWindowDays: number | null; + /** post: the first dated sample at least this many days after the anchor */ + minGapDays: number; +} + +/** + * The cohort's patients with a dated sample on each side of a treatment start, as a `ps` CTE (one + * sample per side: 'a' the last on or before the anchor, 'b' the first at least minGapDays after; + * ties on a day broken by sample id, so the pick is deterministic). A sample's day is the first + * timeline event of the given types naming it, matched to the study's samples by stable id. + */ +function acquiredSidesCtes(cohortSql: string, study: string, db: string, o: AcquiredSides): string { + const a = o.anchor; + const preCond = "rel <= 0" + (o.preWindowDays != null ? " AND rel >= " + (-Math.floor(o.preWindowDays)) : ""); + const postCond = "rel >= " + Math.floor(o.minGapDays); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "anch AS (\n" + + " SELECT p.stable_id AS pid, min(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", a.eventType) + "\n" + + (a.key ? " AND ce.clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + + foldedEqualsSql("key", a.key) + " AND " + foldedEqualsSql("value", String(a.value ?? "")) + ")\n" : "") + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "tl AS (\n" + + " SELECT p.stable_id AS pid, d.value AS sample, min(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("d.key", o.sampleKey) + "\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedInSql("ce.event_type", o.sampleEvents) + "\n" + + " AND p.stable_id IN (SELECT pid FROM anch)\n" + + " GROUP BY pid, sample\n" + + "),\n" + + "dated AS (\n" + + " SELECT tl.pid AS pid, sd.sample_unique_id AS suid, tl.day - anch.day AS rel\n" + + " FROM tl INNER JOIN anch ON anch.pid = tl.pid\n" + + " INNER JOIN " + db + ".sample_derived sd ON sd.cancer_study_identifier = " + sq(study) + " AND sd.sample_stable_id = tl.sample AND sd.patient_stable_id = tl.pid\n" + + "),\n" + + "pick AS (\n" + + " SELECT pid, countIf(" + preCond + ") AS n_pre, countIf(" + postCond + ") AS n_post,\n" + + " argMaxIf(suid, (rel, suid), " + preCond + ") AS pre, argMinIf(suid, (rel, suid), " + postCond + ") AS post,\n" + + " maxIf(rel, " + preCond + ") AS pre_rel, minIf(rel, " + postCond + ") AS post_rel\n" + + " FROM dated GROUP BY pid\n" + + "),\n" + + "ps AS (\n" + + " SELECT pre AS suid, pid, 'a' AS side FROM pick WHERE n_pre > 0 AND n_post > 0\n" + + " UNION ALL SELECT post AS suid, pid, 'b' AS side FROM pick WHERE n_pre > 0 AND n_post > 0\n" + + ")" + ); +} + +/** Per gene, among patients with a sample before and after the anchor, both profiled for it: as pairedCountsSql (a = before, b = after). */ +export function acquiredCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, o: AcquiredSides, + pin: string | null, measure: "driver" | "any"): string { + return acquiredSidesCtes(cohortSql, study, db, o) + ",\n" + sideCountsTail(study, db, genes, pin, measure); +} + +/** The funnel from cohort to pairs: anchored, with a dated sample, with one before, one after, both; and the pairs' spacing in days. */ +export function acquiredFunnelSql(cohortSql: string, study: string, db: string, o: AcquiredSides): string { + const both = "n_pre > 0 AND n_post > 0"; + return ( + acquiredSidesCtes(cohortSql, study, db, o) + "\n" + + "SELECT (SELECT count() FROM anch) AS anchored, (SELECT uniqExact(pid) FROM tl) AS with_events, count() AS dated,\n" + + " countIf(n_pre > 0) AS with_pre, countIf(n_post > 0) AS with_post, countIf(" + both + ") AS paired,\n" + + " quantileExactIf(0.5)(-pre_rel, " + both + ") AS pre_median, quantileExactIf(0.5)(post_rel, " + both + ") AS post_median,\n" + + " quantileExactIf(0.25)(post_rel - pre_rel, " + both + ") AS gap_q1, quantileExactIf(0.5)(post_rel - pre_rel, " + both + ") AS gap_median,\n" + + " quantileExactIf(0.75)(post_rel - pre_rel, " + both + ") AS gap_q3\n" + + "FROM pick" + ); +} + +// --------------------------------------------------------------------------- +// ctdna: a patient's plasma (cfDNA) samples against their tissue samples + +export interface CtdnaSides { + /** the sample attribute that classes samples (SAMPLE_CLASS), its plasma values and its tissue values */ + attribute: string; plasma: string[]; tissue: string[]; + /** samples on these panels are plasma whatever the attribute says (e.g. ACCESS129) */ + plasmaPanels: string[]; + /** keep only samples with a sample of the other kind dated within this many days (timeline dates); null: no dates needed */ + withinDays: number | null; + sampleEvents: string[]; sampleKey: string; + /** + * leave out plasma mutation calls likely from clonal haematopoiesis: those in `genes`, and (with + * `normalMinAlt`) those whose matched normal (white-cell) sample carries at least that many + * alternate reads at an allele fraction at least `normalMinRatio` of the plasma's; absent, every call counts + */ + ch?: { genes: string[]; normalMinAlt: number | null; normalMinRatio: number }; +} + +/** + * The cohort's samples classed plasma ('b') or tissue ('a'), as a `ps` CTE of the patients with + * both; `sides` keeps every classed sample, for questions about plasma alone. With `withinDays`, + * a sample is kept only when the timeline dates it and a sample of the other kind within that + * many days (`dated` and `near` say how many). + */ +function ctdnaSidesCtes(cohortSql: string, study: string, db: string, o: CtdnaSides): string { + const fold = (x: string) => clinicalValueFoldSql(x); + const lit = (vs: string[]) => vs.map((v) => fold(sq(v))).join(","); + const dated = o.withinDays != null; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "cls AS (\n" + + " SELECT sample_unique_id AS suid, any(" + fold("attribute_value") + ") AS v\n" + + " FROM " + clinicalTable(db, [o.attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(o.attribute) + "\n" + + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY suid\n" + + "),\n" + + "classed AS (\n" + + " SELECT sd.sample_unique_id AS suid, sd.patient_stable_id AS pid, sd.sample_stable_id AS sample,\n" + + " multiIf(" + (o.plasmaPanels.length ? "sd.sample_unique_id IN (SELECT sample_unique_id FROM " + db + ".sample_to_gene_panel_derived WHERE cancer_study_identifier = " + sq(study) + + " AND gene_panel_id IN (" + o.plasmaPanels.map(sq).join(",") + ")), 'b', " : "") + + "cls.v IN (" + lit(o.plasma) + "), 'b', cls.v IN (" + lit(o.tissue) + "), 'a', '') AS side\n" + + " FROM " + db + ".sample_derived sd LEFT JOIN cls ON cls.suid = sd.sample_unique_id\n" + + " WHERE sd.cancer_study_identifier = " + sq(study) + " AND sd.patient_stable_id IN (SELECT pid FROM cohort)\n" + + "),\n" + + (dated + ? "tl AS (\n" + + " SELECT p.stable_id AS pid, d.value AS sample, min(ce.start_date) AS day\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id AND " + foldedEqualsSql("d.key", o.sampleKey) + "\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedInSql("ce.event_type", o.sampleEvents) + "\n" + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid, sample\n" + + "),\n" + + "dated AS (SELECT c.suid AS suid, c.pid AS pid, c.side AS side, tl.day AS day FROM classed c INNER JOIN tl ON tl.pid = c.pid AND tl.sample = c.sample WHERE c.side != ''),\n" + + "near AS (\n" + + " SELECT DISTINCT x.suid AS suid FROM dated x INNER JOIN dated y ON y.pid = x.pid\n" + + " WHERE x.side != y.side AND abs(x.day - y.day) <= " + Math.floor(o.withinDays!) + "\n" + + "),\n" + + "sides AS (SELECT suid, pid, side FROM classed WHERE side != '' AND suid IN (SELECT suid FROM near)),\n" + : "sides AS (SELECT suid, pid, side FROM classed WHERE side != ''),\n") + + "paired AS (SELECT pid FROM sides GROUP BY pid HAVING countIf(side = 'a') > 0 AND countIf(side = 'b') > 0),\n" + + "ps AS (SELECT suid, pid, side FROM sides WHERE pid IN (SELECT pid FROM paired))" + + (o.ch ? ",\n" + ctdnaChCte(study, db, o.ch) : "") + ); +} + +/** + * `chx`: the plasma samples' mutation calls a clonal-haematopoiesis rule removes — by gene, or by + * alternate reads in the matched normal (mutation_derived's normal counts, where the study loads them). + */ +function ctdnaChCte(study: string, db: string, ch: NonNullable): string { + const byGene = ch.genes.length ? "g.hugo_gene_symbol IN (" + ch.genes.map(sq).join(",") + ")" : "0"; + const normal = ch.normalMinAlt != null; + return ( + (normal + ? "nm AS (\n" + + " SELECT concat(studyId, '_', sampleId) AS suid, `GENE.hugoGeneSymbol` AS gene, proteinChange AS mv,\n" + + " max(normalAltCount >= " + Math.floor(ch.normalMinAlt!) + " AND normalAltCount / (normalAltCount + normalRefCount) >= " + Number(ch.normalMinRatio) + + " * tumorAltCount / greatest(1, tumorAltCount + tumorRefCount)) AS hit, max(normalAltCount IS NOT NULL) AS loaded\n" + + " FROM " + db + ".mutation_derived\n" + + " WHERE studyId = " + sq(study) + " AND concat(studyId, '_', sampleId) IN (SELECT suid FROM sides WHERE side = 'b')\n" + + " GROUP BY suid, gene, mv\n" + + "),\n" + : "") + + "chx AS (\n" + + " SELECT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene, g.mutation_variant AS mv,\n" + + " max(" + byGene + ") AS by_gene, " + (normal ? "max(ifNull(nm.hit, 0) = 1)" : "toUInt8(0)") + " AS by_normal,\n" + + " " + (normal ? "max(ifNull(nm.loaded, 0) = 1)" : "toUInt8(0)") + " AS has_normal\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (normal ? " LEFT JOIN nm ON nm.suid = g.sample_unique_id AND nm.gene = g.hugo_gene_symbol AND nm.mv = g.mutation_variant\n" : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation'\n" + + " AND g.sample_unique_id IN (SELECT suid FROM sides WHERE side = 'b')\n" + + " GROUP BY suid, gene, mv\n" + + ")" + ); +} + +/** with a CH rule, the condition that keeps an event: not a plasma mutation call `chx` removes (`side` is the side column) */ +const chKeep = (o: CtdnaSides, side: string): string | undefined => o.ch + ? "NOT (" + side + " = 'b' AND g.variant_type = 'mutation' AND (g.sample_unique_id, g.hugo_gene_symbol, g.mutation_variant) IN (SELECT suid, gene, mv FROM chx WHERE by_gene OR by_normal))" + : undefined; + +/** Per gene, among patients with plasma and tissue both profiled for it: as pairedCountsSql (a = tissue, b = plasma). */ +export function ctdnaCountsSql(cohortSql: string, study: string, db: string, genes: string[] | null, o: CtdnaSides, + pin: string | null, measure: "driver" | "any"): string { + return ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + sideCountsTail(study, db, genes, pin, measure, chKeep(o, "ps.side")); +} + +/** + * The patients with a plasma sample, a tissue sample, both (and, with dates, how many classed + * samples were dated); and of those with plasma, how many had any alteration called in it + * (ctDNA detected: a mutation, an amplification or deep deletion, or a structural variant; with + * `driver`, an OncoKB oncogenic one). + */ +export function ctdnaFunnelSql(cohortSql: string, study: string, db: string, o: CtdnaSides, pin: string | null, measure: "driver" | "any"): string { + return ( + ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + + ctdnaDetectedCte(study, db, pin, measure, chKeep(o, "s.side")) + "\n" + + "SELECT (SELECT uniqExactIf(pid, side = 'b') FROM classed) AS plasma_all, (SELECT uniqExactIf(pid, side = 'a') FROM classed) AS tissue_all,\n" + + " (SELECT uniqExactIf(pid, side = 'b') FROM sides) AS plasma, (SELECT uniqExactIf(pid, side = 'a') FROM sides) AS tissue,\n" + + " (SELECT count() FROM paired) AS both, (SELECT count() FROM det) AS detected,\n" + + " (SELECT countIf(side = 'b') FROM ps) AS plasma_samples, (SELECT countIf(side = 'a') FROM ps) AS tissue_samples,\n" + + " " + (o.withinDays != null ? "(SELECT count() FROM dated)" : "toUInt64(0)") + " AS dated_samples" + + (o.ch ? ",\n (SELECT count() FROM chx) AS ch_calls, (SELECT countIf(has_normal) FROM chx) AS ch_with_normal,\n" + + " (SELECT countIf(by_gene) FROM chx) AS ch_by_gene, (SELECT countIf(by_normal) FROM chx) AS ch_by_normal, (SELECT countIf(by_gene OR by_normal) FROM chx) AS ch_removed,\n" + + " (SELECT uniqExact(s.pid) FROM chx INNER JOIN sides s ON s.suid = chx.suid WHERE by_gene OR by_normal) AS ch_patients" : "") + "\n" + + "FROM system.one" + ); +} + +/** the patients with any (driver) alteration called in a plasma sample */ +function ctdnaDetectedCte(study: string, db: string, pin: string | null, measure: "driver" | "any", exclude?: string): string { + return ( + "det AS (\n" + + " SELECT DISTINCT s.pid AS pid FROM " + db + ".genomic_event_derived g\n" + + (pin && measure === "driver" ? driverJoin(pin) : "") + + " INNER JOIN sides s ON s.suid = g.sample_unique_id\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND s.side = 'b' AND " + ALTERED_COND + "\n" + + " AND g.sample_unique_id IN (SELECT suid FROM sides WHERE side = 'b')" + (pin && measure === "driver" ? " AND ann.is_driver" : measure === "driver" ? " AND 0" : "") + + (exclude ? " AND " + exclude : "") + "\n" + + ")" + ); +} + +/** + * Overall survival of the patients with a plasma sample, by ctDNA detected or not: counts of + * (detected, months, dead) — OS_MONTHS / OS_STATUS as the study records them. + */ +export function ctdnaSurvivalSql(cohortSql: string, study: string, db: string, o: CtdnaSides, pin: string | null, measure: "driver" | "any"): string { + const pid = pidOf("patient_unique_id", study); + return ( + ctdnaSidesCtes(cohortSql, study, db, o) + ",\n" + + ctdnaDetectedCte(study, db, pin, measure, chKeep(o, "s.side")) + ",\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead, " + pid + " IN (SELECT pid FROM det) AS detected\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM sides WHERE side = 'b')\n" + + " GROUP BY pid\n" + + ")\n" + + "SELECT detected, round(months, 2) AS t, dead, count() AS n\n" + + "FROM os WHERE months IS NOT NULL AND months >= 0 GROUP BY detected, t, dead ORDER BY t" + ); +} + +/** with a CH rule: per gene, the plasma mutation calls it removes and their patients, by which rule */ +export function ctdnaChRemovedSql(cohortSql: string, study: string, db: string, o: CtdnaSides): string { + if (!o.ch) throw new Error("ctdnaChRemovedSql needs a CH rule"); + return ( + ctdnaSidesCtes(cohortSql, study, db, o) + "\n" + + "SELECT chx.gene AS gene, countIf(by_gene OR by_normal) AS calls, uniqExactIf(s.pid, by_gene OR by_normal) AS patients,\n" + + " countIf(by_gene) AS gene_calls, countIf(by_normal) AS normal_calls\n" + + "FROM chx INNER JOIN sides s ON s.suid = chx.suid\n" + + "GROUP BY gene HAVING calls > 0 ORDER BY calls DESC, gene LIMIT 40" + ); +} + +// --------------------------------------------------------------------------- +// exclusivity: the sample-by-gene alteration matrix, reduced to what DISCOVER's background needs + +/** + * The cohort's samples with a gene panel, each with its panel signature (every panel it was + * profiled on, any alteration type, joined), its stratum (a sample attribute's folded value, or + * '' unstratified), and its altered genes among those its panels cover (any alteration, or OncoKB + * drivers): `sp`, `altc` and `strat` CTEs. + */ +function exclusivityCtes(cohortSql: string, study: string, db: string, stratifyBy: string | null, pin: string | null, measure: "driver" | "any"): string { + const driver = measure === "driver"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "sp AS (\n" + + " SELECT sample_unique_id AS suid, arrayStringConcat(arraySort(groupUniqArray(gene_panel_id)), '+') AS sig\n" + + " FROM " + db + ".sample_to_gene_panel_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND alteration_type IN ('MUTATION_EXTENDED','COPY_NUMBER_ALTERATION','STRUCTURAL_VARIANT')\n" + + " AND sample_unique_id IN (SELECT sample_unique_id FROM " + db + ".sample_derived WHERE cancer_study_identifier = " + sq(study) + + " AND patient_stable_id IN (SELECT pid FROM cohort))\n" + + " GROUP BY suid\n" + + "),\n" + + "cov AS (\n" + + " SELECT DISTINCT s.sig AS sig, pg.gene AS gene\n" + + " FROM (SELECT DISTINCT sig, arrayJoin(splitByChar('+', sig)) AS panel FROM sp) s\n" + + " JOIN " + db + ".gene_panel_to_gene_derived pg ON pg.gene_panel_id = s.panel\n" + + "),\n" + + "altc AS (\n" + + " SELECT DISTINCT g.sample_unique_id AS suid, g.hugo_gene_symbol AS gene\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (driver && pin ? driverJoin(pin) : "") + + " INNER JOIN sp ON sp.suid = g.sample_unique_id\n" + + " INNER JOIN cov ON cov.sig = sp.sig AND cov.gene = g.hugo_gene_symbol\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND " + ALTERED_COND + (driver ? (pin ? " AND ann.is_driver" : " AND 0") : "") + "\n" + + " AND g.sample_unique_id IN (SELECT suid FROM sp)\n" + + "),\n" + + "strat AS (\n" + + (stratifyBy + ? " SELECT sample_unique_id AS suid, any(" + clinicalValueFoldSql("attribute_value") + ") AS v FROM " + clinicalTable(db, [stratifyBy]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(stratifyBy) + " AND sample_unique_id IN (SELECT suid FROM sp)\n" + + " GROUP BY suid\n" + : " SELECT suid, '' AS v FROM sp\n") + + ")" + ); +} + +/** + * The samples grouped by what their background probability depends on — panel signature, + * stratum and burden r (altered covered genes) — and by which of `genes` they have altered (a + * bitmask, bit i for genes[i]): counts only. + */ +export function exclusivityGroupsSql(cohortSql: string, study: string, db: string, genes: string[], stratifyBy: string | null, + pin: string | null, measure: "driver" | "any"): string { + const mask = genes.map((g, i) => "maxIf(" + (2 ** i) + ", altc.gene = " + sq(g) + ")").join(" + ") || "0"; + return ( + exclusivityCtes(cohortSql, study, db, stratifyBy, pin, measure) + ",\n" + + "per AS (SELECT suid, count() AS r, " + mask + " AS amask FROM altc GROUP BY suid)\n" + + "SELECT sp.sig AS sig, ifNull(strat.v, '') AS stratum, per.r AS r, per.amask AS amask, count() AS n\n" + + "FROM sp LEFT JOIN per ON per.suid = sp.suid LEFT JOIN strat ON strat.suid = sp.suid\n" + + "GROUP BY sig, stratum, r, amask" + ); +} + +/** Per stratum and gene, the samples with that gene altered (the background's column sums). */ +export function exclusivityColumnsSql(cohortSql: string, study: string, db: string, stratifyBy: string | null, pin: string | null, measure: "driver" | "any"): string { + return ( + exclusivityCtes(cohortSql, study, db, stratifyBy, pin, measure) + "\n" + + "SELECT ifNull(strat.v, '') AS stratum, altc.gene AS gene, count() AS n\n" + + "FROM altc LEFT JOIN strat ON strat.suid = altc.suid GROUP BY stratum, gene" + ); +} + +/** The genes each panel signature in the cohort covers. Metadata only. */ +export function exclusivityCoverageSql(cohortSql: string, study: string, db: string): string { + return exclusivityCtes(cohortSql, study, db, null, null, "any") + "\nSELECT sig, gene FROM cov"; +} + +// --------------------------------------------------------------------------- +// instability: genome-wide instability scores per sample, from the same segments + +/** + * hg19 centromeres: the boundary between band p11.1 (p11.11 on chr11) and q11 in UCSC's hg19 + * cytoBand table (hgdownload.soe.ucsc.edu/goldenPath/hg19/database/cytoBand.txt.gz). The p arm + * is [0, centromere), the q arm [centromere, chromosome length) (CHROM_LENGTHS.hg19). + */ +export const HG19_CENTROMERES: Record = { + "1": 125000000, "2": 93300000, "3": 91000000, "4": 50400000, "5": 48400000, "6": 61000000, "7": 59900000, + "8": 45600000, "9": 49000000, "10": 40200000, "11": 53700000, "12": 35800000, "13": 17900000, "14": 17600000, + "15": 19000000, "16": 36600000, "17": 24000000, "18": 17200000, "19": 26500000, "20": 27500000, "21": 13200000, + "22": 14700000, "X": 60600000 +}; +/** the acrocentric p arms, which carry no unique sequence to measure — left out, as Taylor et al. 2018 did */ +const ACROCENTRIC = ["13", "14", "15", "21", "22"]; +/** the arms scored: 39 autosomal arms (and Xp, Xq with includeX), in genome order */ +export function instabilityArms(includeX: boolean): string[] { + return GENOME_CHROMS.filter((c) => includeX || c !== "X").flatMap((c) => ACROCENTRIC.includes(c) ? [c + "q"] : [c + "p", c + "q"]); +} + +export interface InstabilityOpts { threshold: number; armShare: number; includeX: boolean } + +/** + * Per segmented sample (in SQL, never returned): its FGA — the share of its segmented length on + * the scored chromosomes whose |segment mean| is past the threshold — and, per arm, the share of + * the arm's length gained and lost; an arm is gained (lost) when that share passes armShare, and + * measured when segments cover at least armShare of it. Aneuploidy score = arms gained or lost. + */ +function instabilityCtes(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { + const T = Number(o.threshold), S = Number(o.armShare); + const chroms = GENOME_CHROMS.filter((c) => o.includeX || c !== "X"); + const arr = (xs: (string | number)[]) => "[" + xs.map((x) => typeof x === "number" ? String(x) : sq(x)).join(",") + "]"; + const cen = "transform(chrom, " + arr([...chroms]) + ", " + arr(chroms.map((c) => HG19_CENTROMERES[c])) + ", 0)"; + const len = "transform(chrom, " + arr([...chroms]) + ", " + arr(chroms.map((c) => CHROM_LENGTHS.hg19[c])) + ", 0)"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "samp AS (\n" + + " SELECT internal_id AS sid FROM " + db + ".sample_derived\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND " + pidOf("patient_unique_id", study) + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "seg AS (\n" + + " SELECT s.sample_id AS sid, multiIf(s.chr IN ('23', 'X', 'chrX'), 'X', replaceOne(s.chr, 'chr', '')) AS chrom,\n" + + " toInt64(s.start) AS st, toInt64(s.end) AS en, s.segment_mean AS m\n" + + " FROM " + db + ".copy_number_seg s\n" + + " WHERE s.cancer_study_id = (SELECT cancer_study_id FROM " + db + ".cancer_study WHERE cancer_study_identifier = " + sq(study) + ")\n" + + " AND s.sample_id IN (SELECT sid FROM samp) AND isFinite(s.segment_mean)\n" + + "),\n" + + "parts AS (\n" + + " SELECT sid, chrom, m, arm, greatest(toInt64(0), least(en, hi) - greatest(st, lo)) AS bp, hi - lo AS arm_len FROM (\n" + + " SELECT sid, chrom, m, st, en, " + cen + " AS c, " + len + " AS l,\n" + + " arrayJoin([(concat(chrom, 'p'), toInt64(0), toInt64(c)), (concat(chrom, 'q'), toInt64(c), toInt64(l))]) AS a,\n" + + " a.1 AS arm, a.2 AS lo, a.3 AS hi\n" + + " FROM seg WHERE chrom IN (" + chroms.map(sq).join(",") + ") AND en > st\n" + + " ) WHERE NOT (arm IN (" + ACROCENTRIC.map((c) => sq(c + "p")).join(",") + "))\n" + + "),\n" + + "arms AS (\n" + + " SELECT sid, arm, any(arm_len) AS alen, sum(bp) AS cov, sumIf(bp, m >= " + T + ") AS gbp, sumIf(bp, m <= " + (-T) + ") AS lbp\n" + + " FROM parts GROUP BY sid, arm\n" + + "),\n" + + "calls AS (\n" + + " SELECT sid, arm, cov >= " + S + " * alen AS measured, gbp > " + S + " * alen AS gain, lbp > " + S + " * alen AS loss FROM arms\n" + + "),\n" + + "per AS (\n" + + " SELECT a.sid AS sid, sum(a.cov) AS cov, sum(a.gbp + a.lbp) AS alt,\n" + + " countIf(c.gain OR c.loss) AS score, countIf(c.measured) AS measured\n" + + " FROM arms a INNER JOIN calls c ON c.sid = a.sid AND c.arm = a.arm GROUP BY a.sid\n" + + ")" + ); +} + +/** FGA in 1-point bins × aneuploidy score, with how many arms each sample had measured: counts of samples. */ +export function instabilityScoresSql(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { + return ( + instabilityCtes(cohortSql, study, db, o) + "\n" + + "SELECT toInt32(least(100, floor(100 * alt / greatest(1, cov)))) AS fga_bin, score, measured, count() AS n\n" + + "FROM per GROUP BY fga_bin, score, measured ORDER BY fga_bin, score" + ); +} + +/** Per arm: the samples that measured it, and those with it gained or lost. */ +export function instabilityArmsSql(cohortSql: string, study: string, db: string, o: InstabilityOpts): string { + return ( + instabilityCtes(cohortSql, study, db, o) + "\n" + + "SELECT arm, countIf(measured) AS n_measured, countIf(measured AND gain) AS n_gain, countIf(measured AND loss) AS n_loss FROM calls GROUP BY arm" + ); +} + // --------------------------------------------------------------------------- // clonality: FACETS allele-specific copy number, per mutation call @@ -1070,3 +1511,1031 @@ export function riskmodelPanelGenesSql(study: string, db: string): string { "GROUP BY panel" ); } + +// --------------------------------------------------------------------------- +// txendpoints: real-world treatment endpoints from a treatment anchor + +/** Days per month on the timeline clock: OS_MONTHS × this is the last follow-up's timeline day. */ +export const DAYS_PER_MONTH = 30.4375; +/** a timeline event this many days past OS_MONTHS × DAYS_PER_MONTH says the two clocks disagree */ +export const CLOCK_SLACK_DAYS = 31; + +export interface TxEndpointOpts { + endpoint: "ttnt" | "tot" | "rwpfs" | "os"; + /** the anchored treatment (event type, optional key = value; the first or last such record) */ + anchor: IncidenceAnchor; + /** how records form lines (lib/treatment-lines.ts) */ + lines: LineSpec; + /** rwpfs: the progression event — its type, key and accepted values, and an optional probability floor */ + progression?: ProgressionEvent; + /** tot: a stop this many days or fewer before the last follow-up of a living patient counts as ongoing */ + ongoingDays?: number; +} + +/** Timeline events of the study's cohort patients, aliased `alias` (the FROM … WHERE of a CTE). */ +function cohortEventsFrom(db: string, study: string, alias: string): string { + return " FROM " + db + ".clinical_event " + alias + "\n" + + " JOIN " + db + ".patient p ON p.internal_id = " + alias + ".patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND p.stable_id IN (SELECT pid FROM cohort)\n"; +} + +/** an event of `alias` carrying `key` (folded) with a value meeting `valueCond` */ +function eventHasKv(db: string, alias: string, key: string, valueCond: string): string { + return " AND " + alias + ".clinical_event_id IN (SELECT clinical_event_id FROM " + db + ".clinical_event_data WHERE " + + foldedEqualsSql("key", key) + " AND " + valueCond + ")\n"; +} + +/** the progression event: its type, key and accepted values, and an optional probability floor */ +export interface ProgressionEvent { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number } + +/** `os`: each cohort patient's OS_MONTHS (months) and whether OS_STATUS says deceased (dead) */ +function osCte(study: string, db: string): string { + const pid = pidOf("patient_unique_id", study); + return "os AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")"; +} + +/** `lastday`: each cohort patient's latest timeline day (a start or a stop) — the clock check's input */ +function lastDayCte(study: string, db: string): string { + return "lastday AS (\n" + + " SELECT p.stable_id AS pid, max(greatest(ce.start_date, coalesce(ce.stop_date, ce.start_date))) AS d\n" + cohortEventsFrom(db, study, "ce") + + " GROUP BY pid\n" + + ")"; +} + +/** `prog`: (pid, day) of every progression event of the cohort's patients */ +function progressionCte(study: string, db: string, pr: ProgressionEvent): string { + return "prog AS (\n" + + " SELECT p.stable_id AS pid, ce.start_date AS day\n" + cohortEventsFrom(db, study, "ce") + + " AND " + foldedEqualsSql("ce.event_type", pr.eventType) + "\n" + + eventHasKv(db, "ce", pr.key, foldedInSql("value", pr.values)) + + (pr.probabilityKey && pr.minProbability != null + ? eventHasKv(db, "ce", pr.probabilityKey, finiteSql("value") + " >= " + Number(pr.minProbability)) : "") + + ")"; +} + +/** + * Per cohort, counts of (entry, t, ev) in months from the anchored treatment's start (day `a`): the + * time to the endpoint (`ev` 1) or to censoring at the last follow-up (`ev` 0), the last follow-up + * being day OS_MONTHS × DAYS_PER_MONTH of the timeline clock and death (OS_STATUS 1:…) an event for + * every endpoint. `entry` is the delay from the anchor to day 0 when the anchor precedes it — the + * left truncation of the survival and incidence views. + * ttnt the start of the next treatment line after the anchor's (lines as lib/treatment-lines.ts + * builds them; for an anchor no line holds, the first line starting after it), or death + * tot the latest stop of the anchored records in the anchor's line (or, outside a line, those + * starting on day `a`), or death; a living patient whose stop lies within `ongoingDays` of + * the last follow-up is censored there as still on it + * rwpfs the first progression event after day `a`, or death + * os death alone (overall survival from the anchor; `lines` is not read) + * Patients who cannot enter are counted under `kind`: 'nofollowup' (no OS, or follow-up ending at + * or before the anchor) and, for tot, 'nostop' (an anchored record of the line without a stop + * date). `late` flags patients with a timeline event more than CLOCK_SLACK_DAYS past their last + * follow-up — the check that the timeline and OS_MONTHS share day 0. A cohort patient with no + * anchored record is in no row; the caller counts them from the cohort's size. + */ +export function txEndpointCountsSql(cohortSql: string, study: string, db: string, o: TxEndpointOpts): string { + const pick = o.anchor.occurrence === "LAST" ? "max" : "min"; + const evFrom = (alias: string) => cohortEventsFrom(db, study, alias); + const hasKv = (alias: string, key: string, valueCond: string) => eventHasKv(db, alias, key, valueCond); + const ctes: string[] = [ + osCte(study, db), + "arec AS (\n" + + " SELECT p.stable_id AS pid, ce.clinical_event_id AS eid, ce.start_date AS s, ce.stop_date AS e\n" + evFrom("ce") + + " AND " + foldedEqualsSql("ce.event_type", o.anchor.eventType) + "\n" + + (o.anchor.key ? hasKv("ce", o.anchor.key, foldedEqualsSql("value", String(o.anchor.value ?? ""))) : "") + + ")", + "anch AS (SELECT pid, " + pick + "(s) AS a FROM arec GROUP BY pid)", + lastDayCte(study, db), + ]; + let x: string; + if (o.endpoint === "os") { + ctes.push("x AS (SELECT pid, toInt64(0) AS d, 0 AS nostop FROM anch WHERE 0)"); + x = "x"; + } else if (o.endpoint === "rwpfs") { + ctes.push( + progressionCte(study, db, o.progression!), + "x AS (SELECT prog.pid AS pid, min(prog.day) AS d, 0 AS nostop FROM prog INNER JOIN anch ON anch.pid = prog.pid WHERE prog.day > anch.a GROUP BY prog.pid)"); + x = "x"; + } else { + ctes.push(...linesCtes({ db, study, patientFilter: "p.stable_id IN (SELECT pid FROM cohort)", spec: o.lines }), + // the anchor's line: the line of a record that is anchored and starts on day a + "aline AS (\n" + + " SELECT n.pid AS pid, min(n.line_no) AS ln FROM numbered n\n" + + " INNER JOIN arec ON arec.eid = n.eid INNER JOIN anch ON anch.pid = n.pid\n" + + " WHERE arec.s = anch.a GROUP BY n.pid\n" + + ")"); + if (o.endpoint === "ttnt") { + ctes.push( + "x AS (\n" + + " SELECT anch.pid AS pid, minIf(l.ls, if(al.pid != '', l.line_no > al.ln, l.ls > anch.a)) AS d,\n" + + " countIf(if(al.pid != '', l.line_no > al.ln, l.ls > anch.a)) AS k, 0 AS nostop\n" + + " FROM anch LEFT JOIN aline al ON al.pid = anch.pid INNER JOIN lines l ON l.pid = anch.pid\n" + + " GROUP BY anch.pid HAVING k > 0\n" + + ")"); + } else { + ctes.push( + "x AS (\n" + + " SELECT anch.pid AS pid, max(arec.e) AS d, countIf(arec.e IS NULL) AS nostop\n" + + " FROM anch INNER JOIN arec ON arec.pid = anch.pid\n" + + " LEFT JOIN aline al ON al.pid = anch.pid LEFT JOIN numbered n ON n.eid = arec.eid\n" + + " WHERE arec.s >= anch.a AND if(al.pid != '', n.line_no = al.ln, arec.s = anch.a)\n" + + " GROUP BY anch.pid\n" + + ")"); + } + x = "x"; + } + const ongoing = Math.max(0, Math.floor(Number(o.ongoingDays ?? 30))); + ctes.push( + "per AS (\n" + + " SELECT anch.a AS a, os.months * " + DAYS_PER_MONTH + " AS fu, os.months IS NOT NULL AS has_os, os.dead AS dead,\n" + + " " + x + ".pid != '' AS has_x, toFloat64(" + x + ".d) AS xd, " + x + ".nostop AS nostop, lastday.d AS ld\n" + + " FROM anch\n" + + " LEFT JOIN os ON os.pid = anch.pid\n" + + " LEFT JOIN " + x + " ON " + x + ".pid = anch.pid\n" + + " LEFT JOIN lastday ON lastday.pid = anch.pid\n" + + ")", + "cls AS (\n" + + " SELECT multiIf(NOT has_os OR fu - a <= 0, 'nofollowup',\n" + + (o.endpoint === "tot" ? " NOT has_x OR nostop > 0 OR xd IS NULL, 'nostop',\n" : "") + + " 'record') AS kind,\n" + + " greatest(0, -a) / " + DAYS_PER_MONTH + " AS entry,\n" + + (o.endpoint === "tot" + ? " (least(xd, fu) - a) / " + DAYS_PER_MONTH + " AS tt,\n" + + " if(NOT dead AND xd + " + ongoing + " >= fu, 0, 1) AS ev,\n" + : " has_x AND xd <= fu AS hit,\n" + + " if(hit, xd - a, fu - a) / " + DAYS_PER_MONTH + " AS tt,\n" + + " if(hit, 1, if(dead, 1, 0)) AS ev,\n") + + " ld > fu + " + CLOCK_SLACK_DAYS + " AS late\n" + + " FROM per\n" + + ")"); + return "WITH cohort AS (" + cohortSql + "),\n" + ctes.join(",\n") + "\n" + + "SELECT kind, if(kind = 'record', round(entry, 4), 0) AS entry, if(kind = 'record', round(tt, 4), 0) AS t,\n" + + " if(kind = 'record', ev, 0) AS ev, late, count() AS n\n" + + "FROM cls GROUP BY kind, entry, t, ev, late ORDER BY t"; +} + +// --------------------------------------------------------------------------- +// multistate: the illness–death model (start → progression → death) from the timeline's day 0 + +/** the event type whose day-0 record says the timeline's clock starts at sequencing */ +export const SEQUENCING_EVENT = "Sequencing"; + +/** + * Per cohort, counts of patient paths through the illness–death model on the timeline's clock, in + * months from day 0: `prevalent` (a progression event on or before day 0, so the patient starts in + * the progression state), `tp` the first progression after day 0 strictly before the end of + * follow-up (0 when none), `te` the end of follow-up — OS_MONTHS, the timeline's last follow-up + * day being OS_MONTHS × DAYS_PER_MONTH — and `dead` (OS_STATUS 1:…). Flags beside them, for the + * caveat: `afterend` a progression recorded only on or after the end of follow-up (not counted), + * `assessed` any record of the progression key at all (a patient never imaged cannot show + * progression), `seq0` a Sequencing event on day 0 (what day 0 is), and `late` a timeline event + * more than CLOCK_SLACK_DAYS past the end of follow-up (the clock check). 'nofollowup' patients + * (no OS, or OS_MONTHS ≤ 0) are counted apart. Every cohort patient is in exactly one row's n. + */ +export function multistateCountsSql(cohortSql: string, study: string, db: string, pr: ProgressionEvent): string { + return "WITH cohort AS (" + cohortSql + "),\n" + [ + osCte(study, db), + lastDayCte(study, db), + progressionCte(study, db, pr), + "pp AS (SELECT pid, countIf(day <= 0) AS n_pre, countIf(day > 0) AS n_after, minIf(day, day > 0) AS first_after FROM prog GROUP BY pid)", + "assessed AS (\n" + + " SELECT DISTINCT p.stable_id AS pid\n" + cohortEventsFrom(db, study, "ce") + + " AND " + foldedEqualsSql("ce.event_type", pr.eventType) + "\n" + + eventHasKv(db, "ce", pr.key, "1") + + ")", + "seq0 AS (\n" + + " SELECT DISTINCT p.stable_id AS pid\n" + cohortEventsFrom(db, study, "ce") + + " AND " + foldedEqualsSql("ce.event_type", SEQUENCING_EVENT) + " AND ce.start_date = 0\n" + + ")", + "per AS (\n" + + " SELECT os.months AS months, os.months IS NOT NULL AS has_os, os.dead AS dead,\n" + + " pp.pid != '' AND pp.n_pre > 0 AS prevalent, pp.pid != '' AND pp.n_after > 0 AS has_after, toFloat64(pp.first_after) AS fa,\n" + + " a.pid != '' AS assessed, s.pid != '' AS seq0, lastday.d AS ld\n" + + " FROM (SELECT DISTINCT pid FROM cohort) c\n" + + " LEFT JOIN os ON os.pid = c.pid\n" + + " LEFT JOIN pp ON pp.pid = c.pid\n" + + " LEFT JOIN assessed a ON a.pid = c.pid\n" + + " LEFT JOIN seq0 s ON s.pid = c.pid\n" + + " LEFT JOIN lastday ON lastday.pid = c.pid\n" + + ")", + "cls AS (\n" + + " SELECT multiIf(NOT has_os OR months <= 0, 'nofollowup', 'record') AS kind,\n" + + " NOT prevalent AND has_after AND fa < months * " + DAYS_PER_MONTH + " AS hit,\n" + + " NOT prevalent AND has_after AND fa >= months * " + DAYS_PER_MONTH + " AS afterend,\n" + + " if(hit, fa / " + DAYS_PER_MONTH + ", 0) AS tp, months AS te,\n" + + " prevalent, dead, assessed, seq0, ld > months * " + DAYS_PER_MONTH + " + " + CLOCK_SLACK_DAYS + " AS late\n" + + " FROM per\n" + + ")", + ].join(",\n") + "\n" + + "SELECT kind, if(kind = 'record', toUInt8(prevalent), 0) AS prevalent, if(kind = 'record', round(tp, 4), 0) AS tp,\n" + + " if(kind = 'record', round(te, 4), 0) AS te, if(kind = 'record', toUInt8(dead), 0) AS dead,\n" + + " toUInt8(afterend) AS afterend, toUInt8(assessed) AS assessed, toUInt8(seq0) AS seq0, toUInt8(late) AS late, count() AS n\n" + + "FROM cls GROUP BY kind, prevalent, tp, te, dead, afterend, assessed, seq0, late ORDER BY te"; +} + +// --------------------------------------------------------------------------- +// cutpoint: a numeric marker against a survival endpoint, per patient + +/** + * One row per cohort patient with a survival time: months `m`, event `ev`, the marker `v` — the + * patient's value, or for a sample attribute the value of the patient's first sample by sample id + * — and, with `stratum`, that attribute's value read the same way as `s`. Values only, in no + * particular order: the patient id joins the CTEs and is never returned. Run only through + * PatientLevelResolver.viewRows, since a permutation test needs each patient's pair. + */ +export function cutpointRowsSql(cohortSql: string, study: string, db: string, + o: { attribute: string; months: string; status: string; stratum?: string | null }): string { + for (const a of [o.attribute, o.stratum, o.months, o.status]) if (a && !/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study); + const attrs = [o.attribute, ...(o.stratum ? [o.stratum] : [])]; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("anyIf(attribute_value, attribute_name = " + sq(o.months) + ")") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = " + sq(o.status) + ") AS status\n" + + " FROM " + clinicalTable(db, [o.months, o.status]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN (" + sq(o.months) + ", " + sq(o.status) + ")\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "val AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("argMinIf(attribute_value, sample_unique_id, attribute_name = " + sq(o.attribute) + ")") + " AS v" + + (o.stratum ? ",\n argMinIf(trimBoth(attribute_value), sample_unique_id, attribute_name = " + sq(o.stratum) + ") AS s" : "") + "\n" + + " FROM " + clinicalTable(db, attrs) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + attrs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")\n" + + "SELECT os.months AS m, if(os.status LIKE '1%', 1, 0) AS ev, val.v AS v" + (o.stratum ? ", val.s AS s" : "") + "\n" + + "FROM os LEFT JOIN val ON val.pid = os.pid\n" + + "WHERE os.months IS NOT NULL AND os.months >= 0" + ); +} +// hla: HLA class I genotype and HLA LOH per patient + +export interface HlaRowsOpts { + /** per locus A, B, C: the attributes that may hold its first allele, its second, or both as one "x/y" value */ + loci: { first: string[]; second: string[]; pair: string[] }[]; + /** allele-name fields compared (1, 2, or 99 for all) */ + fields: number; + lohAttributes: string[]; lohValues: string[]; + months: string; status: string; + covariates: string[]; +} + +/** an allele's name as its fields: hla_a_02_01_01_01, A*02:01 and A0201 all start ["02", "01"] */ +const hlaFieldsSql = (x: string) => { + const y = "replaceRegexpOne(lowerUTF8(" + x + "), '^(hla[-_]?)?[abc][*_]?', '')"; + return "if(match(" + y + ", '[_:]'), splitByRegexp('[_:]', " + y + "), [substring(" + y + ", 1, 2), substring(" + y + ", 3)])"; +}; + +/** + * One row per patient of the cohort: per locus whether its two alleles match at `fields` fields + * (hA, hB, hC; NULL when either allele is missing), the fewest fields any allele was typed at (nf), + * HLA LOH in any sample (loss; NULL when no sample has a call), the endpoint (m, ev) and each + * covariate as c0, c1, … (the patient's, or the first sample's). Values only: the patient id joins + * the CTEs and is never returned. Run only through PatientLevelResolver.viewRows. + */ +export function hlaRowsSql(cohortSql: string, study: string, db: string, o: HlaRowsOpts): string { + const names = [...o.loci.flatMap((l) => [...l.first, ...l.second, ...l.pair]), ...o.lohAttributes, o.months, o.status, ...o.covariates]; + for (const a of names) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study); + const inList = (xs: string[]) => xs.length ? "k IN (" + xs.map(sq).join(", ") + ")" : "0"; + const allele = (l: HlaRowsOpts["loci"][number], i: 1 | 2) => { + const own = "anyIf(v, " + inList(i === 1 ? l.first : l.second) + ")"; + return "if(" + own + " != '', " + own + ", splitByRegexp('\\\\s*[/,;]\\\\s*', anyIf(v, " + inList(l.pair) + "))[" + i + "])"; + }; + const genoAttrs = o.loci.flatMap((l) => [...l.first, ...l.second, ...l.pair]); + const fields = Math.max(1, Math.min(99, Math.floor(o.fields))); + const key = (x: string) => "arrayStringConcat(arraySlice(" + hlaFieldsSql(x) + ", 1, " + fields + "), ':')"; + const homAt = (i: number) => "if(k[" + (2 * i + 1) + "] = '' OR k[" + (2 * i + 2) + "] = '', NULL, toUInt8(k[" + (2 * i + 1) + "] = k[" + (2 * i + 2) + "]))"; + const covs = o.covariates; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "gt AS (\n" + + " SELECT pid, [" + o.loci.flatMap((l) => [allele(l, 1), allele(l, 2)]).join(",\n ") + "] AS raw\n" + + " FROM (SELECT " + pid + " AS pid, attribute_name AS k, trimBoth(attribute_value) AS v FROM " + clinicalTable(db, genoAttrs) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + genoAttrs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort))\n" + + " GROUP BY pid\n" + + "),\n" + + "keys AS (\n" + + " SELECT pid, arrayMap(x -> if(x = '', '', " + key("x") + "), raw) AS k,\n" + + " arrayMin(arrayMap(x -> if(x = '', 99, length(" + hlaFieldsSql("x") + ")), raw)) AS nf0\n" + + " FROM gt\n" + + "),\n" + + "hom AS (SELECT pid, " + homAt(0) + " AS hA, " + homAt(1) + " AS hB, " + homAt(2) + " AS hC, toNullable(nf0) AS nf FROM keys),\n" + + "loh AS (\n" + + " SELECT " + pid + " AS pid, toNullable(max(" + clinicalValueFoldSql("attribute_value") + " IN (" + o.lohValues.map((v) => clinicalValueFoldSql(sq(v))).join(", ") + "))) AS loss\n" + + " FROM " + clinicalTable(db, o.lohAttributes) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + o.lohAttributes.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid,\n" + + " " + finiteSql("anyIf(attribute_value, attribute_name = " + sq(o.months) + ")") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = " + sq(o.status) + ") AS status\n" + + " FROM " + clinicalTable(db, [o.months, o.status]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN (" + sq(o.months) + ", " + sq(o.status) + ")\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" + + (covs.length + ? ",\ncov AS (\n" + + " SELECT " + pid + " AS pid" + covs.map((c, i) => ",\n toNullable(argMinIf(trimBoth(attribute_value), sample_unique_id, attribute_name = " + sq(c) + ")) AS c" + i).join("") + "\n" + + " FROM " + clinicalTable(db, covs) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + covs.map(sq).join(", ") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")\n" + : "\n") + + "SELECT hom.hA AS hA, hom.hB AS hB, hom.hC AS hC, hom.nf AS nf, loh.loss AS loss,\n" + + " if(os.months >= 0, os.months, NULL) AS m, if(os.status LIKE '1%', 1, 0) AS ev" + covs.map((_, i) => ", cov.c" + i + " AS c" + i).join("") + "\n" + + "FROM cohort LEFT JOIN hom ON hom.pid = cohort.pid LEFT JOIN loh ON loh.pid = cohort.pid LEFT JOIN os ON os.pid = cohort.pid" + + (covs.length ? " LEFT JOIN cov ON cov.pid = cohort.pid" : "") + ); +} + +/** the attribute names in a study that mention HLA, with how many values each holds */ +export function hlaAttributesSql(_cohortSql: string, study: string, db: string): string { + return "SELECT attribute_name AS name, any(type) AS type, count() AS n FROM " + clinicalTable(db, []) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name ILIKE '%hla%' AND trimBoth(attribute_value) != ''\n" + + "GROUP BY name ORDER BY name LIMIT 40"; +} + +// adjusted: each gene's alteration between two cohorts, by logistic regression on covariates + +/** + * One row per patient of the cohort with a mutation panel: the panels their samples were sequenced + * on, the genes mutated in them (called mutations only; OncoKB drivers only with `driverPin`), and + * each covariate's value as c0, c1, … (the patient's, or the first sample's). Values only: the + * patient id joins the CTEs and is never returned. Run only through PatientLevelResolver.viewRows. + */ +export function adjustedRowsSql(cohortSql: string, study: string, db: string, o: { covariates: string[]; driverPin?: string | null }): string { + const { covariates, driverPin = null } = o; + for (const a of covariates) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study), sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "panels AS (\n" + + " SELECT " + sdPid + " AS pid, groupUniqArray(toString(sp.gene_panel_id)) AS panels\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED'\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "mut AS (\n" + + " SELECT " + gPid + " AS pid, groupUniqArray(g.hugo_gene_symbol) AS genes\n" + + " FROM " + db + ".genomic_event_derived g\n" + + (driverPin ? driverJoin(driverPin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'\n" + + (driverPin ? " AND ann.is_driver = 1\n" : "") + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" + + (covariates.length ? + ",\nattrs AS (\n" + + " SELECT " + pid + " AS pid,\n" + + covariates.map((a, k) => " argMinIf(attribute_value, sample_unique_id, attribute_name = " + sq(a) + ") AS a" + k).join(",\n") + "\n" + + " FROM " + clinicalTable(db, covariates) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name IN (" + covariates.map(sq).join(",") + ") AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" : "") + "\n" + + "SELECT panels.panels AS panels, mut.genes AS genes" + covariates.map((_, k) => ", attrs.a" + k + " AS c" + k).join("") + "\n" + + "FROM panels\n" + + " LEFT JOIN mut ON mut.pid = panels.pid\n" + + (covariates.length ? " LEFT JOIN attrs ON attrs.pid = panels.pid\n" : "") + ); +} + +// --------------------------------------------------------------------------- +// actionability: each patient's highest OncoKB therapeutic level + +/** OncoKB's sensitive levels, best first, and the resistance levels; rank 9 is none */ +export const SENSITIVE_LEVELS = ["LEVEL_1", "LEVEL_2", "LEVEL_3A", "LEVEL_3B", "LEVEL_4"] as const; +export const RESISTANCE_LEVELS = ["LEVEL_R1", "LEVEL_R2"] as const; +const rankOf = (col: string, levels: readonly string[]) => "multiIf(" + levels.map((l, i) => col + " = " + sq(l) + ", " + (i + 1)).join(", ") + ", 9)"; + +/** the cohort's samples sequenced for mutations, and per sample its best sensitive and resistance + * rank over its called mutations and high-level copy-number calls (OncoKB at `pin`) */ +function actionCtes(cohortSql: string, study: string, db: string, pin: string): string { + const sdPid = pidOf("sd.patient_unique_id", study); + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "seq AS (\n" + + " SELECT DISTINCT sp.sample_unique_id AS sid, " + sdPid + " AS pid\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED'\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "lv AS (\n" + + " SELECT g.sample_unique_id AS sid, min(" + rankOf("ann.highest_sensitive_level", SENSITIVE_LEVELS) + ") AS s,\n" + + " min(" + rankOf("ann.highest_resistance_level", RESISTANCE_LEVELS) + ") AS r\n" + + " FROM " + db + ".genomic_event_derived g\n" + + // the store's rows with a level at the pin only — a few thousand of its millions — matched as driverJoin matches + " INNER JOIN (SELECT hugo_gene_symbol, alteration_type, alteration, highest_sensitive_level, highest_resistance_level\n" + + " FROM oncokb_annotation.variant_annotation\n" + + " WHERE oncokb_version = " + sq(pin) + " AND (highest_sensitive_level != '' OR highest_resistance_level != '')) ann\n" + + " ON ann.hugo_gene_symbol = g.hugo_gene_symbol\n" + + " AND ((g.variant_type = 'mutation' AND ann.alteration_type = 'MUTATION' AND ann.alteration = g.mutation_variant)\n" + + " OR (g.variant_type = 'cna' AND ann.alteration_type = 'CNA' AND ann.alteration = if(g.cna_alteration >= 2, 'AMPLIFICATION', 'DELETION'))\n" + + " OR (g.variant_type = 'structural_variant' AND ann.alteration_type = 'SV' AND ann.alteration = 'Fusion'))\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + "\n" + + " AND ((g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED') OR (g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2) OR g.variant_type = 'structural_variant')\n" + + " AND g.sample_unique_id IN (SELECT sid FROM seq)\n" + + " GROUP BY sid\n" + + ")" + ); +} + +/** + * Per group (a clinical attribute's value per patient — "Multiple" when their samples disagree, + * "" when none is recorded — or one group without `groupBy`) and per pair of a patient's best + * sensitive and resistance ranks over every sequenced sample: the number of patients. Counts only. + */ +export function actionLevelsSql(cohortSql: string, study: string, db: string, pin: string, groupBy: string | null): string { + if (groupBy && !/^[A-Za-z0-9_:/.-]{1,128}$/.test(groupBy)) throw new Error("bad attribute name '" + String(groupBy).slice(0, 40) + "'"); + const pid = pidOf("patient_unique_id", study); + return ( + actionCtes(cohortSql, study, db, pin) + ",\n" + + "pl AS (\n" + + " SELECT seq.pid AS pid, min(if(lv.s = 0, 9, lv.s)) AS s, min(if(lv.r = 0, 9, lv.r)) AS r\n" + + " FROM seq LEFT JOIN lv ON lv.sid = seq.sid\n" + + " GROUP BY pid\n" + + ")" + + (groupBy ? + ",\ngrp AS (\n" + + " SELECT " + pid + " AS pid, if(uniqExact(attribute_value) > 1, 'Multiple', any(attribute_value)) AS g\n" + + " FROM " + clinicalTable(db, [groupBy]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(groupBy) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")" : "") + "\n" + + "SELECT " + (groupBy ? "grp.g" : "''") + " AS grp, pl.s AS level, pl.r AS resistance, count() AS n\n" + + "FROM pl" + (groupBy ? " LEFT JOIN grp ON grp.pid = pl.pid" : "") + "\n" + + "GROUP BY grp, level, resistance" + ); +} + +/** + * Patients with a sequenced sample on each side of a sample attribute (e.g. SAMPLE_TYPE Primary + * against Metastasis, values matched case-insensitively): per pair of their best sensitive rank + * on side a and on side b, the number of patients. Counts only. + */ +export function actionConcordanceSql(cohortSql: string, study: string, db: string, pin: string, by: { attribute: string; a: string[]; b: string[] }): string { + if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(by.attribute)) throw new Error("bad attribute name '" + String(by.attribute).slice(0, 40) + "'"); + const side = (vals: string[]) => "lower(trimBoth(attribute_value)) IN (" + vals.map((v) => sq(String(v).trim().toLowerCase())).join(", ") + ")"; + return ( + actionCtes(cohortSql, study, db, pin) + ",\n" + + "st AS (\n" + + " SELECT sample_unique_id AS sid, multiIf(" + side(by.a) + ", 'a', " + side(by.b) + ", 'b', '') AS side\n" + + " FROM " + clinicalTable(db, [by.attribute]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'sample' AND attribute_name = " + sq(by.attribute) + "\n" + + " AND sample_unique_id IN (SELECT sid FROM seq)\n" + + "),\n" + + "ps AS (\n" + + " SELECT seq.pid AS pid, minIf(if(lv.s = 0, 9, lv.s), st.side = 'a') AS sa, minIf(if(lv.s = 0, 9, lv.s), st.side = 'b') AS sb,\n" + + " countIf(st.side = 'a') AS na, countIf(st.side = 'b') AS nb\n" + + " FROM seq INNER JOIN st ON st.sid = seq.sid LEFT JOIN lv ON lv.sid = seq.sid\n" + + " GROUP BY pid\n" + + " HAVING na > 0 AND nb > 0\n" + + ")\n" + + "SELECT sa AS a, sb AS b, count() AS n FROM ps GROUP BY a, b" + ); +} + +/** The alteration types the store holds a therapeutic level for at `pin` (reference data, no cohort). */ +export function actionStoreTypesSql(_study: string, _db: string, pin: string): string { + return "SELECT toString(alteration_type) AS type, count() AS n FROM oncokb_annotation.variant_annotation\n" + + "WHERE oncokb_version = " + sq(pin) + " AND (highest_sensitive_level != '' OR highest_resistance_level != '')\nGROUP BY type"; +} + +// --------------------------------------------------------------------------- +// drivernegative: patients without a driver in a canonical list, against those with one + +/** a gene list as SQL literals, checked */ +function geneList(genes: string[]): string { + for (const g of genes) if (!/^[A-Za-z0-9_.-]{1,40}$/.test(g)) throw new Error("bad gene symbol '" + String(g).slice(0, 40) + "'"); + return genes.map(sq).join(", "); +} + +/** + * Each sequenced patient's group: "positive" with an OncoKB driver (mutation, high-level copy + * number or structural variant) in one of `drivers`; else "negative" when a mutation panel of + * theirs covers every listed gene (or whole-exome); else "unprofiled". With `type`, also `pp`: + * each patient's panels for that alteration type and whether one is whole-exome. + */ +function dnegCtes(cohortSql: string, study: string, db: string, pin: string, drivers: string[], type: EnrichAlteration): string { + const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study), list = geneList(drivers); + const panelsOf = (name: string, alterationType: string) => name + " AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(alterationType) + "\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + ")"; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + panelsOf("mp", "MUTATION_EXTENDED") + ",\n" + + panelsOf("pp", ENRICH_PANEL_TYPE[type]) + ",\n" + + "cov AS (\n" + + " SELECT mp.pid AS pid, max(mp.panel = 'WES') AS wes, uniqExactIf(gp.gene, gp.gene IN (" + list + ")) AS ng\n" + + " FROM mp LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = mp.panel\n" + + " GROUP BY pid\n" + + "),\n" + + "drv AS (\n" + + " SELECT DISTINCT " + gPid + " AS pid\n" + + " FROM " + db + ".genomic_event_derived g\n" + driverJoin(pin) + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + list + ") AND ann.is_driver\n" + + " AND ((g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED') OR (g.variant_type = 'cna' AND abs(g.cna_alteration) >= 2) OR g.variant_type = 'structural_variant')\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "grp AS (\n" + + " SELECT pid, multiIf(pid IN (SELECT pid FROM drv), 'positive', wes = 1 OR ng = " + new Set(drivers).size + ", 'negative', 'unprofiled') AS g FROM cov\n" + + ")" + ); +} + +/** Patients per group (positive, negative, unprofiled), of the cohort's patients sequenced for mutations. Counts only. */ +export function dnegGroupsSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[]): string { + return dnegCtes(cohortSql, study, db, pin, drivers, "mutation") + "\nSELECT g AS grp, count() AS n FROM grp GROUP BY grp"; +} + +/** Per gene outside `drivers` and per group (positive, negative): patients with that alteration in it + * (OncoKB drivers only with `driverOnly`). Counts only. */ +export function dnegAlteredSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[], alteration: EnrichAlteration, driverOnly: boolean): string { + const gPid = pidOf("g.patient_unique_id", study); + return ( + dnegCtes(cohortSql, study, db, pin, drivers, alteration) + "\n" + + "SELECT g.hugo_gene_symbol AS gene, grp.g AS grp, uniqExact(" + gPid + ") AS altered\n" + + "FROM " + db + ".genomic_event_derived g\n" + (driverOnly ? driverJoin(pin) : "") + + " INNER JOIN grp ON grp.pid = " + gPid + "\n" + + "WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(alteration) + "\n" + + " AND g.hugo_gene_symbol NOT IN (" + geneList(drivers) + ") AND grp.g IN ('positive', 'negative')" + (driverOnly ? " AND ann.is_driver" : "") + "\n" + + "GROUP BY gene, grp" + ); +} + +/** Per gene and group: patients not on whole-exome with a panel covering the gene for that alteration + * type; gene '' carries each group's whole-exome patients, profiled for every gene. Counts only. */ +export function dnegProfiledSql(cohortSql: string, study: string, db: string, pin: string, drivers: string[], alteration: EnrichAlteration): string { + return ( + dnegCtes(cohortSql, study, db, pin, drivers, alteration) + ",\n" + + "pw AS (SELECT pid, max(panel = 'WES') AS wes FROM pp GROUP BY pid)\n" + + "SELECT if(pw.wes = 1, '', gp.gene) AS gene, grp.g AS grp, uniqExact(pp.pid) AS profiled\n" + + "FROM pp INNER JOIN pw ON pw.pid = pp.pid INNER JOIN grp ON grp.pid = pp.pid\n" + + " LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel\n" + + "WHERE grp.g IN ('positive', 'negative')\n" + + "GROUP BY gene, grp" + ); +} + +// --------------------------------------------------------------------------- +// organotropism: which genes go with metastasis to which organ + +export type OrgSourceKind = "flags" | "timeline" | "sample"; +export interface OrgSource { + kind: OrgSourceKind; + /** flags: the per-organ attributes' common prefix (e.g. DMETS_DX_), valued Yes / No */ + prefix?: string; + /** timeline: the event type and the key whose value names the site */ + eventType?: string; key?: string; + /** sample: the attribute naming a metastasis sample's biopsy site, mapped through ORG_SITE_MAP */ + attribute?: string; +} + +/** + * The organ a free-text biopsy site is counted under: the first rule whose pattern matches the + * lower-cased value; anything else is "Other". The rules are ours, written against the public + * METASTATIC_SITE vocabularies (msk_met_2021, msk_chord_2024, msk_impact_50k_2026), and every + * result lists the values that fell to "Other". + */ +const ORG_SITE_MAP: [string, string][] = [ + ["Lymph node", "lymph|\\bln\\b|node|axilla"], + ["CNS/Brain", "brain|cereb|csf|epidural|dura\\b|dural|mening|spinal cord|cns"], + ["Liver", "liver|hepat"], + ["Lung", "^lung|pulmon|\\blung\\b"], + ["Pleura", "pleura"], + ["Bone", "bone|spine|vertebr|\\brib\\b|femur|iliac|sacr|stern|scapula|humer|skull|acetab|clavic|tibia|ischi|pubic|calvari"], + ["Adrenal gland", "adrenal"], + ["Peritoneum / abdomen", "periton|omentum|mesenter|abdom|diaphragm|intra-abdominal|ascites"], + ["Bowel / GI tract", "bowel|colon|rect|intestin|duoden|ileum|jejun|append|stomach|gastr|esophag|biliary|gallbladder|pancrea"], + ["Skin / soft tissue", "skin|scalp|soft tissue|chest wall|subcut|muscle|\\bback\\b|paraspinal|breast"], + ["Genital organs", "ovar|uter|vagin|adnex|endomet|cervix|fallop|genital|testi|prostat|vulva"], + ["Kidney / urinary tract", "kidney|renal|bladder|ureter|urethr|\\but\\b"], + ["Head and neck", "head|neck|thyroid|parotid|oral|tongue|larynx|orbit"], + ["Mediastinum / heart", "mediastin|pericard|heart"], +]; + +/** A biopsy-site value's organ, in SQL (ORG_SITE_MAP's rules over the lower-cased, trimmed value). */ +function orgSiteMapSql(expr: string): string { + const v = "lowerUTF8(trimBoth(" + expr + "))"; + return "multiIf(" + ORG_SITE_MAP.map(([organ, re]) => "match(" + v + ", " + sq(re) + "), " + sq(organ)).join(", ") + ", 'Other')"; +} + +const orgAttr = (a: string) => { if (!VIEW_ATTR_RE.test(a)) throw new Error("bad attribute name '" + String(a).slice(0, 40) + "'"); return a; }; +const ORG_YES = "('yes', 'y', 'true', '1')", ORG_NO = "('no', 'n', 'false', '0')"; + +/** + * `ps`: one row per (patient, site) the source can speak to, `has` 1 when the patient has a + * metastasis there; `sl`: each site's label. Flags: a patient is evaluable for a site when its + * flag is recorded Yes or No. Timeline: a patient with any site recorded is evaluable for every + * site seen. Sample: every cohort patient is evaluable; a site is one a sample was taken from. + */ +function orgSiteCtes(study: string, db: string, src: OrgSource): string { + const pid = pidOf("patient_unique_id", study); + if (src.kind === "flags") { + const prefix = orgAttr(src.prefix || "DMETS_DX_"); + return ( + "fl AS (\n" + + " SELECT " + pid + " AS pid, substring(attribute_name, " + (prefix.length + 1) + ") AS site,\n" + + " max(" + clinicalValueFoldSql("attribute_value") + " IN " + ORG_YES + ") AS has,\n" + + " max(" + clinicalValueFoldSql("attribute_value") + " IN " + ORG_YES + " OR " + clinicalValueFoldSql("attribute_value") + " IN " + ORG_NO + ") AS known\n" + + " FROM " + clinicalTable(db, []) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND startsWith(attribute_name, " + sq(prefix) + ") AND length(attribute_name) > " + prefix.length + "\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid, site\n" + + "),\n" + + "ps AS (SELECT pid, site, toUInt8(has) AS has FROM fl WHERE known),\n" + + "sl AS (SELECT DISTINCT site, site AS label FROM ps)" + ); + } + if (src.kind === "timeline") { + return ( + "evr AS (\n" + + " SELECT DISTINCT p.stable_id AS pid, " + nameFoldSql("d.value") + " AS site, trimBoth(d.value) AS raw\n" + + " FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", src.eventType || "Diagnosis") + "\n" + + " AND " + foldedEqualsSql("d.key", src.key || "TUMOR_SITE") + " AND trimBoth(d.value) != ''\n" + + " AND p.stable_id IN (SELECT pid FROM cohort)\n" + + "),\n" + + "ev AS (SELECT DISTINCT pid, site FROM evr),\n" + + "sl AS (SELECT site, min(raw) AS label FROM evr GROUP BY site),\n" + + "ps AS (\n" + + " SELECT e.pid AS pid, s.site AS site, toUInt8((e.pid, s.site) IN (SELECT pid, site FROM ev)) AS has\n" + + " FROM (SELECT DISTINCT pid FROM ev) e CROSS JOIN (SELECT site FROM sl) s\n" + + ")" + ); + } + const attr = orgAttr(src.attribute || "METASTATIC_SITE"); + return ( + "sm AS (\n" + + " SELECT DISTINCT " + pid + " AS pid, " + orgSiteMapSql("attribute_value") + " AS site\n" + + " FROM " + clinicalTable(db, [attr]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "sl AS (SELECT DISTINCT site, site AS label FROM sm),\n" + + "ps AS (\n" + + " SELECT c.pid AS pid, s.site AS site, toUInt8((c.pid, s.site) IN (SELECT pid, site FROM sm)) AS has\n" + + " FROM (SELECT DISTINCT pid FROM cohort) c CROSS JOIN (SELECT site FROM sl) s\n" + + ")" + ); +} + +/** Which site sources the cohort's patients carry: patients with a flag attribute (and how many + * flags), with a site on the timeline, and with a biopsy-site value. Counts only. */ +export function orgSourcesSql(cohortSql: string, study: string, db: string, o: { prefix: string; eventType: string; key: string; attribute: string }): string { + const pid = pidOf("patient_unique_id", study), prefix = orgAttr(o.prefix), attr = orgAttr(o.attribute); + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT\n" + + " (SELECT uniqExact(" + pid + ") FROM " + clinicalTable(db, []) + " WHERE cancer_study_identifier = " + sq(study) + + " AND startsWith(attribute_name, " + sq(prefix) + ") AND " + pid + " IN (SELECT pid FROM cohort)) AS flag_patients,\n" + + " (SELECT uniqExact(attribute_name) FROM " + clinicalTable(db, []) + " WHERE cancer_study_identifier = " + sq(study) + + " AND startsWith(attribute_name, " + sq(prefix) + ") AND " + pid + " IN (SELECT pid FROM cohort)) AS flags,\n" + + " (SELECT uniqExact(p.stable_id) FROM " + db + ".clinical_event ce\n" + + " JOIN " + db + ".clinical_event_data d ON d.clinical_event_id = ce.clinical_event_id\n" + + " JOIN " + db + ".patient p ON p.internal_id = ce.patient_id\n" + + " JOIN " + db + ".cancer_study cs ON cs.cancer_study_id = p.cancer_study_id\n" + + " WHERE cs.cancer_study_identifier = " + sq(study) + " AND " + foldedEqualsSql("ce.event_type", o.eventType) + " AND " + foldedEqualsSql("d.key", o.key) + + " AND trimBoth(d.value) != '' AND p.stable_id IN (SELECT pid FROM cohort)) AS timeline_patients,\n" + + " (SELECT uniqExact(" + pid + ") FROM " + clinicalTable(db, [attr]) + " WHERE cancer_study_identifier = " + sq(study) + + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != '' AND " + pid + " IN (SELECT pid FROM cohort)) AS sample_patients\n" + + "FROM system.one" + ); +} + +/** Per site: patients evaluable for it and those with a metastasis there. Counts only. */ +export function orgSitesSql(cohortSql: string, study: string, db: string, src: OrgSource): string { + return ( + "WITH cohort AS (" + cohortSql + "),\n" + orgSiteCtes(study, db, src) + "\n" + + "SELECT ps.site AS site, any(sl.label) AS label, count() AS evaluable, sum(ps.has) AS with_site\n" + + "FROM ps INNER JOIN sl ON sl.site = ps.site GROUP BY site" + ); +} + +/** Sample source: the biopsy-site values that ORG_SITE_MAP sends to "Other", with their patients. Counts only. */ +export function orgOtherValuesSql(cohortSql: string, study: string, db: string, attribute: string): string { + const pid = pidOf("patient_unique_id", study), attr = orgAttr(attribute); + return ( + "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT trimBoth(attribute_value) AS value, uniqExact(" + pid + ") AS patients\n" + + "FROM " + clinicalTable(db, [attr]) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort) AND " + orgSiteMapSql("attribute_value") + " = 'Other'\n" + + "GROUP BY value ORDER BY patients DESC, value LIMIT 30" + ); +} + +export interface OrgGenes { genes: string[] | null; alteration: EnrichAlteration; driverPin: string | null } + +/** `prof` (patient, gene) for every listed gene a patient's panel covers for the alteration type (whole-exome + * covers all); `alt` the same pairs with the alteration called on a covering panel (OncoKB drivers only with + * driverPin). Without a gene list, `alt` holds every gene. Both read `cohort`. */ +function orgGeneCtes(study: string, db: string, g: OrgGenes): string { + const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study); + const list = g.genes ? geneList(g.genes) : null; + return ( + "pp AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(ENRICH_PANEL_TYPE[g.alteration]) + "\n" + + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + (list + ? "prof AS (\n" + + " SELECT DISTINCT pp.pid AS pid, gp.gene AS gene FROM pp JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel WHERE gp.gene IN (" + list + ")\n" + + " UNION DISTINCT\n" + + " SELECT pid, arrayJoin([" + list + "]) AS gene FROM pp WHERE panel = 'WES'\n" + + "),\n" + : "") + + "alt AS (\n" + + " SELECT DISTINCT " + gPid + " AS pid, g.hugo_gene_symbol AS gene\n" + + " FROM " + db + ".genomic_event_derived g\n" + (g.driverPin ? driverJoin(g.driverPin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND " + enrichEventCond(g.alteration) + " AND NOT g.off_panel\n" + + (list ? " AND g.hugo_gene_symbol IN (" + list + ")\n" : "") + (g.driverPin ? " AND ann.is_driver\n" : "") + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + ")" + ); +} + +/** The cohort's most altered genes (patients with the alteration on a covering panel), at most `limit`. Counts only. */ +export function orgTopGenesSql(cohortSql: string, study: string, db: string, g: Omit, limit: number): string { + return ( + "WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, { ...g, genes: null }) + "\n" + + "SELECT gene, count() AS altered FROM alt GROUP BY gene ORDER BY altered DESC, gene LIMIT " + Math.max(1, Math.min(200, Math.floor(limit))) + ); +} + +/** + * Per (site, gene, stratum): the patients evaluable for the site AND profiled for the gene, those + * of them with a metastasis there, those altered, and those both — the four margins of one 2×2 + * per stratum (a patient's `stratifyBy` value; "Multiple" when their samples disagree, '' when none + * is recorded). Counts only. + */ +export function orgCountsSql(cohortSql: string, study: string, db: string, src: OrgSource, g: OrgGenes & { genes: string[] }, stratifyBy: string | null): string { + const pid = pidOf("patient_unique_id", study); + const strat = stratifyBy ? orgAttr(stratifyBy) : null; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + orgSiteCtes(study, db, src) + ",\n" + orgGeneCtes(study, db, g) + ",\n" + + "st AS (\n" + + (strat + ? " SELECT " + pid + " AS pid, if(uniqExact(" + clinicalValueFoldSql("attribute_value") + ") > 1, 'Multiple', any(trimBoth(attribute_value))) AS s\n" + + " FROM " + clinicalTable(db, [strat]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(strat) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + : " SELECT pid, '' AS s FROM cohort\n") + + ")\n" + + "SELECT ps.site AS site, prof.gene AS gene, st.s AS stratum, count() AS n, sum(ps.has) AS with_site,\n" + + " countIf(alt.pid != '') AS altered, countIf(ps.has = 1 AND alt.pid != '') AS both\n" + + "FROM ps INNER JOIN prof ON prof.pid = ps.pid\n" + + " LEFT JOIN alt ON alt.pid = ps.pid AND alt.gene = prof.gene\n" + + " LEFT JOIN st ON st.pid = ps.pid\n" + + "GROUP BY site, gene, stratum" + ); +} + +/** + * Cumulative incidence of the first timeline site event (src's event type, key = `siteValue`) with + * death competing, for the cohort's patients profiled for `gene` and altered in it (`altered`) or + * not: incidenceCountsSql over that half of the cohort. Counts only. + */ +export function orgIncidenceSql(cohortSql: string, study: string, db: string, src: OrgSource, g: OrgGenes & { genes: [string] }, altered: boolean, siteValue: string): string { + const half = "SELECT pid FROM (" + cohortSql + ") WHERE pid IN (\n WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, g) + "\n" + + " SELECT pid FROM prof)\n AND pid " + (altered ? "" : "NOT ") + "IN (\n WITH cohort AS (" + cohortSql + "),\n" + orgGeneCtes(study, db, g) + "\n SELECT pid FROM alt)"; + return incidenceCountsSql(half, study, db, { eventType: src.eventType || "Diagnosis", attrs: [{ key: src.key || "TUMOR_SITE", value: siteValue }] }, null); +} + +// --------------------------------------------------------------------------- +// molsubtype: a published molecular classifier applied per patient + +export interface MolMsiRule { + attribute: string; + /** a categorical MSI call: the values meaning MSI-high and those meaning stable (case-insensitive) */ + high?: string[]; stable?: string[]; + /** a numeric score: MSI-high at or above `threshold`, stable below `stableBelow`, in between indeterminate */ + threshold?: number; stableBelow?: number; +} +export interface MolEndometrialOpts { + /** POLE exonuclease-domain hotspots (protein changes) */ + poleHotspots: string[]; + msi: MolMsiRule; + /** an abnormal p53: an OncoKB-driver TP53 mutation at this pin, or (null) any non-synonymous TP53 mutation */ + p53Pin: string | null; + /** a patient attribute to cross the subtypes with */ + by: string | null; +} + +/** TP53 mutation classes counted as abnormal without OncoKB: protein-changing calls */ +const MOL_TP53_CLASSES = ["Missense_Mutation", "Nonsense_Mutation", "Frame_Shift_Del", "Frame_Shift_Ins", "Splice_Site", "In_Frame_Del", "In_Frame_Ins", "Nonstop_Mutation", "Translation_Start_Site", "Splice_Region"]; + +/** + * `cls`: each cohort patient's endometrial molecular class — the TCGA / ProMisE / WHO 2020 hierarchy, + * first match wins: 'POLEmut' (a POLE hotspot called in any sample), 'MMRd' (MSI-high in any sample), + * 'p53abn' (an abnormal TP53 mutation), 'NSMP' (none of these) — and 'NC' (not classifiable) with + * `why` when an input the hierarchy needs before deciding is missing: not sequenced, not profiled for + * POLE or TP53 (panel-aware), or no MSI value. Also `grp`, the `by` attribute per patient. + */ +function molEndometrialCtes(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { + const sdPid = pidOf("sd.patient_unique_id", study), gPid = pidOf("g.patient_unique_id", study), pid = pidOf("patient_unique_id", study); + const attr = orgAttr(o.msi.attribute); + const hot = o.poleHotspots.map((h) => { if (!/^[A-Z][0-9]{1,5}[A-Z*]$/.test(h)) throw new Error("bad POLE hotspot '" + String(h).slice(0, 20) + "'"); return sq(h); }).join(", "); + const v = clinicalValueFoldSql("attribute_value"); + const msiState = o.msi.threshold != null + ? "multiIf(" + finiteSql("trimBoth(attribute_value)") + " IS NULL OR " + finiteSql("trimBoth(attribute_value)") + " < 0, 0, " + finiteSql("trimBoth(attribute_value)") + " >= " + Number(o.msi.threshold) + + ", 2, " + finiteSql("trimBoth(attribute_value)") + " < " + Number(o.msi.stableBelow ?? o.msi.threshold) + ", 1, 0)" + : "multiIf(" + v + " IN (" + (o.msi.high || []).map((x) => sq(x.trim().toLowerCase())).join(", ") + "), 2, " + v + " IN (" + (o.msi.stable || []).map((x) => sq(x.trim().toLowerCase())).join(", ") + "), 1, 0)"; + const by = o.by ? orgAttr(o.by) : null; + return ( + "WITH cohort AS (" + cohortSql + "),\n" + + "pp AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp\n" + + " JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = 'MUTATION_EXTENDED' AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + "cov AS (\n" + + " SELECT pp.pid AS pid, max(pp.panel = 'WES' OR gp.gene = 'POLE') AS pole, max(pp.panel = 'WES' OR gp.gene = 'TP53') AS tp53\n" + + " FROM pp LEFT JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = pp.panel AND gp.gene IN ('POLE', 'TP53')\n" + + " GROUP BY pid\n" + + "),\n" + + "mut AS (\n" + + " SELECT " + gPid + " AS pid,\n" + + " max(g.hugo_gene_symbol = 'POLE' AND g.mutation_variant IN (" + hot + ")) AS pole_hot,\n" + + " max(g.hugo_gene_symbol = 'TP53' AND " + (o.p53Pin ? "ann.is_driver" : "g.mutation_type IN (" + MOL_TP53_CLASSES.map(sq).join(", ") + ")") + ") AS p53\n" + + " FROM " + db + ".genomic_event_derived g\n" + (o.p53Pin ? driverJoin(o.p53Pin) : "") + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED'\n" + + " AND lowerUTF8(g.mutation_status) != 'germline' AND NOT g.off_panel AND g.hugo_gene_symbol IN ('POLE', 'TP53')\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "msi AS (\n" + + " SELECT " + pid + " AS pid, max(" + msiState + ") AS m\n" + + " FROM " + clinicalTable(db, [attr]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(attr) + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "cls AS (\n" + + " SELECT c.pid AS pid,\n" + + " multiIf(cov.pid = '', 'NC', mut.pole_hot = 1, 'POLEmut', cov.pole = 0, 'NC', msi.m = 2, 'MMRd', msi.m = 0, 'NC', cov.tp53 = 0, 'NC', mut.p53 = 1, 'p53abn', 'NSMP') AS cls,\n" + + " multiIf(cov.pid = '', 'not sequenced', mut.pole_hot = 1, '', cov.pole = 0, 'not profiled for POLE', msi.m = 2, '', msi.m = 0, 'no MSI call', cov.tp53 = 0, 'not profiled for TP53', '') AS why\n" + + " FROM (SELECT DISTINCT pid FROM cohort) c\n" + + " LEFT JOIN cov ON cov.pid = c.pid LEFT JOIN mut ON mut.pid = c.pid LEFT JOIN msi ON msi.pid = c.pid\n" + + "),\n" + + "grp AS (\n" + + (by + ? " SELECT " + pid + " AS pid, if(uniqExact(" + v + ") > 1, 'Multiple', any(trimBoth(attribute_value))) AS g\n" + + " FROM " + clinicalTable(db, [by]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND attribute_name = " + sq(by) + " AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + : " SELECT pid, '' AS g FROM cohort\n") + + ")" + ); +} + +/** Patients per (class, why, group). Counts only. */ +export function molCountsSql(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { + return molEndometrialCtes(cohortSql, study, db, o) + "\n" + + "SELECT cls.cls AS cls, cls.why AS why, grp.g AS grp, count() AS n FROM cls LEFT JOIN grp ON grp.pid = cls.pid GROUP BY cls, why, grp"; +} + +/** Overall survival per class: counts of (class, months, dead) from OS_MONTHS / OS_STATUS. Counts only. */ +export function molSurvivalSql(cohortSql: string, study: string, db: string, o: MolEndometrialOpts): string { + const pid = pidOf("patient_unique_id", study); + return molEndometrialCtes(cohortSql, study, db, o) + ",\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + ")\n" + + "SELECT cls.cls AS cls, round(os.months, 2) AS t, os.dead AS dead, count() AS n\n" + + "FROM cls INNER JOIN os ON os.pid = cls.pid WHERE os.months IS NOT NULL AND os.months >= 0 AND cls.cls != 'NC' GROUP BY cls, t, dead ORDER BY t"; +} + +/** Which MSI attributes the cohort's samples carry, with how many values and patients. Counts only. */ +export function molMsiAttributesSql(cohortSql: string, study: string, db: string): string { + const pid = pidOf("patient_unique_id", study); + return "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT attribute_name AS attribute, uniqExact(" + pid + ") AS patients, uniqExact(attribute_value) AS nvalues, arraySort(groupUniqArray(8)(attribute_value)) AS examples\n" + + "FROM " + clinicalTable(db, []) + "\n" + + "WHERE cancer_study_identifier = " + sq(study) + " AND positionCaseInsensitive(attribute_name, 'MSI') > 0 AND trimBoth(attribute_value) != ''\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + "GROUP BY attribute ORDER BY patients DESC"; +} + +// --------------------------------------------------------------------------- +// germline: carriers of a germline variant and their somatic second hits + +const GERMLINE = "lowerUTF8(trimBoth(g.mutation_status)) = 'germline'"; + +/** Per gene: the cohort's patients with a germline call in it. Counts only. */ +export function germCarriersSql(cohortSql: string, study: string, db: string): string { + const gPid = pidOf("g.patient_unique_id", study); + return "WITH cohort AS (" + cohortSql + ")\n" + + "SELECT g.hugo_gene_symbol AS gene, uniqExact(" + gPid + ") AS carriers\n" + + "FROM " + db + ".genomic_event_derived g\n" + + "WHERE g.cancer_study_identifier = " + sq(study) + " AND g.variant_type = 'mutation' AND " + GERMLINE + "\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + "GROUP BY gene ORDER BY carriers DESC, gene LIMIT 500"; +} + +/** (patient, gene) pairs a panel of theirs covers for one alteration type (whole-exome covers all), over `genes`. */ +function germProfiledCte(name: string, study: string, db: string, alterationType: string, list: string): string { + const sdPid = pidOf("sd.patient_unique_id", study); + return name + "p AS (\n" + + " SELECT DISTINCT " + sdPid + " AS pid, toString(sp.gene_panel_id) AS panel\n" + + " FROM " + db + ".sample_to_gene_panel_derived sp JOIN " + db + ".sample_derived sd ON sd.sample_unique_id = sp.sample_unique_id\n" + + " WHERE sp.cancer_study_identifier = " + sq(study) + " AND sp.alteration_type = " + sq(alterationType) + " AND " + sdPid + " IN (SELECT pid FROM cohort)\n" + + "),\n" + + name + " AS (\n" + + " SELECT DISTINCT x.pid AS pid, gp.gene AS gene FROM " + name + "p x JOIN " + db + ".gene_panel_to_gene_derived gp ON gp.gene_panel_id = x.panel WHERE gp.gene IN (" + list + ")\n" + + " UNION DISTINCT\n" + + " SELECT pid, arrayJoin([" + list + "]) AS gene FROM " + name + "p WHERE panel = 'WES'\n" + + ")"; +} + +function germCtes(cohortSql: string, study: string, db: string, genes: string[]): string { + const gPid = pidOf("g.patient_unique_id", study), list = geneList(genes); + const calls = (name: string, cond: string) => name + " AS (\n" + + " SELECT DISTINCT " + gPid + " AS pid, g.hugo_gene_symbol AS gene FROM " + db + ".genomic_event_derived g\n" + + " WHERE g.cancer_study_identifier = " + sq(study) + " AND g.hugo_gene_symbol IN (" + list + ") AND " + cond + "\n" + + " AND " + gPid + " IN (SELECT pid FROM cohort)\n" + + ")"; + return "WITH cohort AS (" + cohortSql + "),\n" + + germProfiledCte("mprof", study, db, "MUTATION_EXTENDED", list) + ",\n" + + germProfiledCte("cprof", study, db, "COPY_NUMBER_ALTERATION", list) + ",\n" + + calls("germ", "g.variant_type = 'mutation' AND " + GERMLINE) + ",\n" + + calls("som", "g.variant_type = 'mutation' AND g.mutation_status != 'UNCALLED' AND NOT (" + GERMLINE + ") AND NOT g.off_panel") + ",\n" + + calls("del", "g.variant_type = 'cna' AND g.cna_alteration <= -2 AND NOT g.off_panel") + ",\n" + + "base AS (SELECT pid, gene FROM mprof UNION DISTINCT SELECT pid, gene FROM germ)"; +} + +/** + * Per gene and combination of: profiled for mutations (mp), a germline call (carrier), a somatic + * mutation (som, any call not germline and not uncalled, on a covering panel), profiled for copy number + * (cp) and a deep deletion (del): the number of patients. Carriers whose tumour panel does not list the + * gene appear with mp = 0. Counts only. + */ +export function germCountsSql(cohortSql: string, study: string, db: string, genes: string[]): string { + return germCtes(cohortSql, study, db, genes) + ",\n" + + "fl AS (\n" + + " SELECT gene, toUInt8((pid, gene) IN (SELECT pid, gene FROM mprof)) AS mp, toUInt8((pid, gene) IN (SELECT pid, gene FROM germ)) AS carrier,\n" + + " toUInt8((pid, gene) IN (SELECT pid, gene FROM som)) AS som, toUInt8((pid, gene) IN (SELECT pid, gene FROM cprof)) AS cp,\n" + + " toUInt8((pid, gene) IN (SELECT pid, gene FROM del)) AS del\n" + + " FROM base\n" + + ")\n" + + "SELECT gene, mp, carrier, som, cp, del, count() AS n FROM fl GROUP BY gene, mp, carrier, som, cp, del"; +} + +/** Overall survival of the patients profiled for `gene`, by carrier status: counts of (carrier, months, dead). Counts only. */ +export function germSurvivalSql(cohortSql: string, study: string, db: string, gene: string): string { + const pid = pidOf("patient_unique_id", study); + return germCtes(cohortSql, study, db, [gene]) + ",\n" + + "os AS (\n" + + " SELECT " + pid + " AS pid, " + finiteSql("anyIf(attribute_value, attribute_name = 'OS_MONTHS')") + " AS months,\n" + + " anyIf(attribute_value, attribute_name = 'OS_STATUS') LIKE '1%' AS dead\n" + + " FROM " + clinicalTable(db, ["OS_MONTHS", "OS_STATUS"]) + "\n" + + " WHERE cancer_study_identifier = " + sq(study) + " AND type = 'patient' AND attribute_name IN ('OS_MONTHS', 'OS_STATUS')\n" + + " AND " + pid + " IN (SELECT pid FROM cohort)\n" + + " GROUP BY pid\n" + + "),\n" + + "sv AS (\n" + + " SELECT toUInt8(mprof.pid IN (SELECT pid FROM germ)) AS carrier, os.months AS months, os.dead AS dead\n" + + " FROM mprof INNER JOIN os ON os.pid = mprof.pid WHERE os.months IS NOT NULL AND os.months >= 0\n" + + ")\n" + + "SELECT carrier, round(months, 2) AS t, dead, count() AS n FROM sv GROUP BY carrier, t, dead ORDER BY t"; +} diff --git a/lib/figures/acquired.ts b/lib/figures/acquired.ts new file mode 100644 index 0000000..8c8fad1 --- /dev/null +++ b/lib/figures/acquired.ts @@ -0,0 +1,84 @@ +// Acquired-under-therapy figure: per gene, patients who lost an alteration (left) or acquired one +// (right) between their sample before a treatment start and their sample after it. +import { + esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, wrapWidth, + fmtInt, pDigits, textOn, FS, SW, SERIES, type SvgOpts +} from "../svg-kit.ts"; +import type { DrawnResultOf, AcquiredGene } from "../types.ts"; + +const PAD = 8, GUT = 14, ROW = 20, BAR = 14, MIN_BAR_W = 220; + +export function acquiredSvg(v: DrawnResultOf<"acquired">, o: SvgOpts): string { + const p = palette(o.theme), r = v.acquired; + let W = Math.max(480, o.width || 960); + const LOST = SERIES[0], GAINED = SERIES[1]; + const genes = r.panels.flatMap((pn) => pn.genes); + const qOf = (g: AcquiredGene) => g.acquired + g.lost ? pDigits(g.q) : "–"; + const cols: { head: string; text: (g: AcquiredGene) => string; w: number }[] = [ + { head: "shared", text: (g) => fmtInt(g.shared), w: 0 }, + { head: "pairs", text: (g) => fmtInt(g.pairs), w: 0 }, + { head: "McNemar q", text: qOf, w: 0 } + ]; + for (const c of cols) c.w = Math.max(textWidth(c.head, FS.small), ...genes.map((g) => textWidth(c.text(g), FS.small)), 0); + const statW = cols.reduce((s, c) => s + c.w + GUT, 0); + const geneW = Math.max(30, ...genes.map((g) => textWidth(g.gene, FS.body, 600))); + const need = PAD + geneW + GUT + MIN_BAR_W + statW + PAD; + const grown = need > W; + if (grown) W = Math.ceil(need); + const BL = PAD + geneW + GUT, BR = W - PAD - statW; + // one symmetric scale for every panel, lost to the left of zero and acquired to the right + const widest = Math.max(1, ...genes.map((g) => Math.max(g.acquired, g.lost))); + const xs = linear(-widest, widest, BL, BR); + const x0 = xs(0); + const out: string[] = []; + let y = 4; + const lg = legend([{ label: "lost (before only)", color: LOST }, { label: "acquired (after only)", color: GAINED }], PAD, y, W - 2 * PAD, p, { size: FS.small }); + out.push(lg.svg); + y += lg.height + 6; + + r.panels.forEach((pn, pi) => { + if (pi) y += 12; + const head = pn.cohort, hw = Math.min(textWidth(head, FS.title, 600), W * 0.5); + out.push(label(PAD, y + FS.title, head, { size: FS.title, weight: 600, fill: p.ink, maxW: W * 0.5 })); + const detail = fmtInt(pn.patients) + " patients sampled before and after" + + (pn.gapDays ? " · samples a median " + fmtInt(pn.gapDays.median) + " days apart" : "") + + (pn.tested != null ? " · " + fmtInt(pn.tested) + " genes tested, " + (pn.genes.length < pn.tested ? "the " + fmtInt(pn.genes.length) + " smallest p" : "all") + " drawn" : ""); + const lines = wrapWidth(detail, W - PAD - hw - 12 - PAD, FS.small); + lines.forEach((l, i) => out.push(label(PAD + hw + 12, y + FS.title + i * (FS.small + 3), l, { size: FS.small, fill: p.muted }))); + y += FS.title + (lines.length - 1) * (FS.small + 3) + 8; + let cx = BR; + for (const c of cols) { cx += GUT; out.push(label(cx + c.w, y + FS.small, c.head, { size: FS.small, fill: p.muted, anchor: "end" })); cx += c.w; } + y += FS.small + 4; + const top = y; + for (const tk of xs.ticks) out.push(``); + pn.genes.forEach((g, gi) => { + const ry = top + gi * ROW, cy = ry + ROW / 2; + if (gi % 2 === 0) out.push(``); + out.push(label(BL - GUT, cy + FS.body * 0.35, g.gene, { size: FS.body, weight: 600, fill: p.ink, anchor: "end" })); + for (const [n, col, name, sign] of [[g.lost, LOST, "lost", -1], [g.acquired, GAINED, "acquired", 1]] as [number, string, string, number][]) { + if (n <= 0) continue; + const w = Math.max(1, Math.abs(xs(sign * n) - x0)), x = sign < 0 ? x0 - w : x0; + out.push(`${esc(g.gene + " — " + fmtInt(n) + (n === 1 ? " patient " : " patients ") + name)}`); + const t = fmtInt(n); + if (textWidth(t, FS.small) + 6 <= w) out.push(label(x + w / 2, cy + FS.small * 0.35, t, { size: FS.small, fill: textOn(col), anchor: "middle" })); + } + if (!g.pairs) out.push(label(x0 + 4, cy + FS.small * 0.35, "never covered on both samples", { size: FS.small, fill: p.muted })); + const sig = g.q < 0.05 && g.acquired + g.lost > 0; + let sx = BR; + for (const c of cols) { + sx += GUT; + out.push(label(sx + c.w, cy + FS.small * 0.35, c.text(g), { size: FS.small, fill: sig ? p.ink : p.muted, anchor: "end", weight: sig && c.head === "McNemar q" ? 600 : 400 })); + sx += c.w; + } + }); + out.push(``); + y = top + pn.genes.length * ROW; + }); + const axTitle = "patients: ← lost after treatment · acquired after treatment →"; + out.push(axisBottom(xs, y, p, { title: axTitle, minX: BL - GUT, maxX: BR + GUT, fmt: (t) => fmtInt(Math.abs(t)) })); + y += bottomAxisHeight(axTitle) + 8; + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 10, out.join("") + cap.svg, p.bg, grown, "Acquired under therapy — from " + r.anchoredTo); +} + +const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/figures/actionability.ts b/lib/figures/actionability.ts new file mode 100644 index 0000000..d40d628 --- /dev/null +++ b/lib/figures/actionability.ts @@ -0,0 +1,89 @@ +// Actionability figure: per cohort (and group), the share of sequenced patients at each highest OncoKB +// level as one stacked bar, then each cohort's primary/metastasis concordance as a cross-table. +import type { DrawnResultOf, ActionLevels } from "../types.ts"; +import { esc, palette, frame, caption, label, textWidth, legend, linear, axisBottom, bottomAxisHeight, seriesStyle, textOn, fmtInt, fmtPct, FS, SW, OTHER, type SvgOpts } from "../svg-kit.ts"; + +const ROW = 22, PAD = 8, GUT = 12; +const levelColor = (i: number) => i < 5 ? seriesStyle(i).color : OTHER; + +export function actionabilitySvg(v: DrawnResultOf<"actionability">, o: SvgOpts): string { + const p = palette(o.theme), r = v.actionability, want = o.width || 936, W = Math.max(520, want); + const pct = (k: number, d: number) => d ? fmtPct(100 * k / d) : "–"; + const actionable = (g: ActionLevels) => g.levels.slice(0, 4).reduce((a, b) => a + b, 0); + type Row = { label: string; g: ActionLevels; head?: boolean }; + const rows: Row[] = r.panels.flatMap((pn) => [{ label: pn.cohort, g: pn, head: true }, + ...(pn.groups || []).map((g) => ({ label: g.label, g }))]); + const sideText = (g: ActionLevels) => "1–3B " + pct(actionable(g), g.sequenced) + " · R " + pct(g.resistance[0] + g.resistance[1], g.sequenced); + const nText = (g: ActionLevels) => "n = " + fmtInt(g.sequenced); + const labW = Math.min(W * 0.3, Math.max(...rows.map((x) => textWidth(x.label, FS.body, x.head ? 600 : 400) + (x.head ? 0 : 10)))); + const nW = Math.max(...rows.map((x) => textWidth(nText(x.g), FS.small))); + const sW = Math.max(...rows.map((x) => textWidth(sideText(x.g), FS.small))); + const L = PAD + labW + GUT + nW + GUT, R = W - PAD - sW - GUT; + const xs = linear(0, 100, L, R, { nice: false }); + const out: string[] = []; + const lg = legend(r.levels.map((l, i) => ({ label: l, color: levelColor(i) })), PAD, 4, W - 2 * PAD, p); + out.push(lg.svg); + let y = 4 + lg.height + 6; + const top = y; + rows.forEach((x, i) => { + if (x.head && i) y += 6; + const cy = y + ROW / 2, ty = cy + FS.body * 0.35; + out.push(label(PAD + (x.head ? 0 : 10), ty, x.label, { size: FS.body, fill: p.ink, weight: x.head ? 600 : 400, maxW: labW - (x.head ? 0 : 10) })); + out.push(label(PAD + labW + GUT + nW, ty, nText(x.g), { size: FS.small, fill: p.muted, anchor: "end" })); + let acc = 0; + x.g.levels.forEach((k, li) => { + if (!k || !x.g.sequenced) return; + const a = xs(acc), share = 100 * k / x.g.sequenced, b = xs(acc + share); + acc += share; + out.push(`${esc(x.label + ": " + r.levels[li] + " " + fmtInt(k) + " of " + fmtInt(x.g.sequenced) + " (" + pct(k, x.g.sequenced) + ")")}`); + if (b - a > textWidth(fmtPct(share, 0), FS.small) + 4) + out.push(label((a + b) / 2, cy + FS.small * 0.35, fmtPct(share, 0), { size: FS.small, fill: textOn(levelColor(li)), anchor: "middle" })); + }); + out.push(label(R + GUT, ty, sideText(x.g), { size: FS.small, fill: p.ink })); + y += ROW; + }); + const axTitle = "% of patients sequenced, by their highest sensitive level (in any cancer type)"; + out.push(``); + out.push(axisBottom(xs, y + 2, p, { title: axTitle, minX: L - 20, maxX: R + 20 })); + y += 2 + bottomAxisHeight(axTitle) + 14; + + // concordance: rows are side a's highest level, columns side b's + const conc = r.panels.filter((pn) => pn.concordance); + if (r.pairBy && conc.length) { + const short = ["1", "2", "3A", "3B", "4", "none"]; + const cellW = Math.max(36, textWidth("00,000", FS.small) + 8); + const rowLab = r.pairBy.a.join("/") + " ↓ " + r.pairBy.b.join("/") + " →"; + const lw = Math.max(textWidth(rowLab, FS.small), textWidth("none", FS.small)) + GUT; + const tableW = lw + 6 * cellW; + const per = Math.max(1, Math.floor((W - PAD) / (tableW + 24))); + for (let k = 0; k < conc.length; k += per) { + const chunk = conc.slice(k, k + per); + let h = 0; + chunk.forEach((pn, j) => { + const c = pn.concordance!, x0 = PAD + j * (tableW + 24); + let yy = y; + out.push(label(x0, yy + FS.body, pn.cohort + ": " + fmtInt(c.pairs) + " patients, " + pct(c.discordant, c.pairs) + " discordant", { size: FS.body, fill: p.ink, weight: 600, maxW: tableW })); + yy += FS.body + 10; + out.push(label(x0, yy + FS.small, rowLab, { size: FS.small, fill: p.muted, maxW: lw - 4 })); + short.forEach((s, ci) => out.push(label(x0 + lw + ci * cellW + cellW / 2, yy + FS.small, s, { size: FS.small, fill: p.muted, anchor: "middle" }))); + yy += FS.small + 6; + const max = Math.max(1, ...c.table.flat()); + c.table.forEach((row, ri) => { + out.push(label(x0 + lw - GUT, yy + 14, short[ri], { size: FS.small, fill: p.muted, anchor: "end" })); + row.forEach((n, ci) => { + const cx = x0 + lw + ci * cellW, diag = ri === ci; + out.push(``); + out.push(label(cx + cellW / 2, yy + 13, n ? fmtInt(n) : "·", { size: FS.small, fill: p.ink, anchor: "middle" })); + }); + yy += 20; + }); + h = Math.max(h, yy - y); + }); + y += h + 14; + } + } + const cap = caption("Bars: each patient once, at their highest sensitive level; the right column is the share at levels 1–3B and the share with a resistance level (R1/R2) besides. " + (v.caveat || ""), W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, W > want, "Clinical actionability — OncoKB " + r.pin); +} + +const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/figures/adjusted.ts b/lib/figures/adjusted.ts new file mode 100644 index 0000000..8474385 --- /dev/null +++ b/lib/figures/adjusted.ts @@ -0,0 +1,71 @@ +// Adjusted enrichment figure: the volcano of each gene's adjusted log2 odds ratio against −log₁₀ q, +// then the top genes' table, raw beside adjusted. +import type { DrawnResultOf } from "../types.ts"; +import { volcano, volcanoPlotH, type VolcanoPoint } from "./volcano.ts"; +import { palette, frame, caption, label, textWidth, wrapWidth, seriesStyle, fmtInt, fmtPct, fmtRatio, pDigits, FS, SW, type SvgOpts } from "../svg-kit.ts"; + +const TABLE_ROWS = 15; +/** the smallest 1, 2 or 5 × 10^k at or above x: an axis end a reader can read off */ +const niceCeil = (x: number) => { const k = 10 ** Math.floor(Math.log10(x)); return [1, 2, 5, 10].map((m) => m * k).find((v) => v >= x - 1e-12)!; }; + +export function adjustedSvg(v: DrawnResultOf<"adjusted">, o: SvgOpts): string { + const p = palette(o.theme), r = v.adjusted, want = o.width || 936, W = Math.max(520, want); + const lq = (q: number) => -Math.log10(Math.max(1e-300, q)); + const x = (g: { or: number }) => Math.log2(g.or); + const xMax = niceCeil(Math.max(1, ...r.rows.map((g) => Math.abs(x(g))))); + const yMax = niceCeil(Math.max(2, ...r.rows.map((g) => lq(g.q)))); + const color = (g: { q: number; or: number }) => g.q >= 0.05 ? p.muted : seriesStyle(g.or > 1 ? 0 : 1).color; + const pct = (a: number, n: number) => n ? fmtPct(100 * a / n) : "–"; + const pts: VolcanoPoint[] = r.rows.map((g) => ({ key: g.gene, x: x(g), p: g.q, color: color(g), + tip: g.gene + ": " + r.groups.map((c, i) => c + " " + pct(g.altered[i], g.profiled[i])).join(", ") + " · adjusted OR " + fmtRatio(g.or) + ", q " + pDigits(g.q) })); + const out: string[] = []; + const head = fmtInt(r.rows.length) + " genes fit · " + fmtInt(r.significant) + " at q < 0.05 after adjusting for " + (r.covariates.length ? r.covariates.join(", ") : "nothing") + + (r.failed.length ? " · " + fmtInt(r.failed.length) + " not fit (listed below)" : ""); + const hl = wrapWidth(head, W - 16, FS.body); + hl.forEach((l, i) => out.push(label(8, 14 + i * Math.round(FS.body * 1.4), l, { size: FS.body, fill: p.muted }))); + let y = 14 + hl.length * Math.round(FS.body * 1.4) + 6; + const vw = Math.min(W, 720); + const vol = volcano(p, { x0: 0, y0: y, width: vw, plotH: volcanoPlotH(vw), grid: { xMax, yMax, nx: 1, ny: 1, cells: [] }, pts, labels: r.labels, + xTitle: "Adjusted log2 odds ratio", sides: [r.groups[0], r.groups[1]], r: 3, pName: "q" }); + out.push(vol.svg); + y += vol.height + 20; + + // the table: Gene · altered % per cohort · raw OR (Fisher p) · adjusted OR [CI] · q + const shown = r.rows.slice(0, TABLE_ROWS); + type Col = { head: string; anchor: "start" | "end"; text: (g: (typeof shown)[number]) => string; w: number; bold?: (g: (typeof shown)[number]) => boolean }; + const col = (head: string, anchor: "start" | "end", text: Col["text"], bold?: Col["bold"]): Col => + ({ head, anchor, text, bold, w: Math.ceil(Math.max(Math.min(150, textWidth(head, FS.body, 600)), ...shown.map((g) => textWidth(text(g), FS.body, 600)))) }); + const cols: Col[] = [ + col("Gene", "start", (g) => g.gene, () => true), + ...r.groups.map((c, i) => col(c, "end", (g) => fmtInt(g.altered[i]) + "/" + fmtInt(g.profiled[i]) + " (" + pct(g.altered[i], g.profiled[i]) + ")")), + col("Raw OR (Fisher p)", "end", (g) => (g.rawOr == null ? "–" : fmtRatio(g.rawOr)) + " (" + pDigits(g.fisherP) + ")"), + col("Adjusted OR [95% CI]", "end", (g) => fmtRatio(g.or) + " [" + fmtRatio(g.ciLow) + "–" + fmtRatio(g.ciHigh) + "]"), + col("q", "end", (g) => pDigits(g.q), (g) => g.q < 0.05), + ]; + const CG = 14; + const tableW = 8 + cols.reduce((a, c) => a + c.w + CG, 0); + const Wt = Math.max(W, Math.ceil(tableW)); + let cx = 8; + const at: number[] = []; + for (const c of cols) { + const xx = c.anchor === "end" ? cx + c.w : cx; + at.push(xx); + out.push(label(xx, y, c.head, { size: FS.body, fill: p.ink, weight: 600, anchor: c.anchor, maxW: c.w })); + cx += c.w + CG; + } + out.push(``); + const ROW = 18; + shown.forEach((g, i) => { + const yy = y + 6 + (i + 1) * ROW; + cols.forEach((c, j) => out.push(label(at[j], yy, c.text(g), { size: FS.body, fill: p.ink, anchor: c.anchor, weight: c.bold && c.bold(g) ? 600 : 400 }))); + }); + y += 6 + shown.length * ROW + 6; + if (r.rows.length > shown.length) { + y += FS.small + 4; + out.push(label(8, y, "+" + fmtInt(r.rows.length - shown.length) + " more genes fit (the full table is in the result and the MCP text)", { size: FS.small, fill: p.muted, maxW: Wt - 16 })); + } + y += 6; + const cap = caption(v.caveat || "", Wt, y, p.muted); + return frame(Wt, y + cap.height + 8, out.join("") + cap.svg, p.bg, Wt > want, + "Adjusted enrichment — " + r.groups.join(" vs ") + (r.covariates.length ? ", adjusted for " + r.covariates.join(", ") : "")); +} diff --git a/lib/figures/ctdna.ts b/lib/figures/ctdna.ts new file mode 100644 index 0000000..7da4ff2 --- /dev/null +++ b/lib/figures/ctdna.ts @@ -0,0 +1,136 @@ +// ctDNA-vs-tissue figure: per gene, patients altered in both, tissue only and plasma only (with +// plasma's positive percent agreement), then overall survival by ctDNA detected or not. +import { + esc, palette, frame, caption, linear, axisBottom, axisLeft, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, wrapWidth, + fmtInt, fmtShare, fmtP, fmtNum, fmtMonths, textOn, seriesStyle, timeScale, timeTitle, stepPath, paragraph, FS, SW, OTHER, SERIES, type SvgOpts +} from "../svg-kit.ts"; +import { plotHeight } from "./common.ts"; +import type { DrawnResultOf, CtdnaGene, CtdnaPanel } from "../types.ts"; + +const PAD = 8, GUT = 14, ROW = 20, BAR = 14, MIN_BAR_W = 200; + +export function ctdnaSvg(v: DrawnResultOf<"ctdna">, o: SvgOpts): string { + const p = palette(o.theme), r = v.ctdna; + let W = Math.max(480, o.width || 960); + const TISSUE = SERIES[0], PLASMA = SERIES[1], BOTH = OTHER; + const genes = r.panels.flatMap((pn) => pn.genes); + const ppa = (g: CtdnaGene) => g.ppa == null ? "–" : fmtShare(g.ppa, 0) + " [" + fmtShare(g.ppaLow!, 0) + "–" + fmtShare(g.ppaHigh!, 0) + "]"; + const cols: { head: string; text: (g: CtdnaGene) => string; w: number }[] = [ + { head: "PPA [95% CI]", text: ppa, w: 0 }, + { head: "concordant", text: (g) => g.concordance == null ? "–" : fmtShare(g.concordance, 0), w: 0 }, + { head: "pairs", text: (g) => fmtInt(g.pairs), w: 0 } + ]; + if (r.chFilter) { + const before = new Map(r.panels.flatMap((pn) => (pn.ch ? pn.ch.before.genes : []).map((g) => [pn.cohort + "\u0000" + g.gene, g] as const))); + const at = (g: CtdnaGene) => before.get(r.panels.find((pn) => pn.genes.includes(g))!.cohort + "\u0000" + g.gene); + cols.unshift({ head: "plasma-only unfiltered", text: (g) => { const b = at(g); return b ? fmtInt(b.plasmaOnly) : "–"; }, w: 0 }); + } + for (const c of cols) c.w = Math.max(textWidth(c.head, FS.small), ...genes.map((g) => textWidth(c.text(g), FS.small)), 0); + const statW = cols.reduce((s, c) => s + c.w + GUT, 0); + const geneW = Math.max(30, ...genes.map((g) => textWidth(g.gene, FS.body, 600))); + const need = PAD + geneW + GUT + MIN_BAR_W + statW + PAD; + const grown = need > W; + if (grown) W = Math.ceil(need); + const BL = PAD + geneW + GUT, BR = W - PAD - statW; + const widest = Math.max(1, ...genes.map((g) => g.both + g.tissueOnly + g.plasmaOnly)); + const xs = linear(0, widest, BL, BR); + const out: string[] = []; + let y = 4; + const lg = legend([{ label: "tissue only", color: TISSUE }, { label: "both", color: BOTH }, { label: "plasma only", color: PLASMA }], PAD, y, W - 2 * PAD, p, { size: FS.small }); + out.push(lg.svg); + y += lg.height + 6; + + r.panels.forEach((pn, pi) => { + if (pi) y += 14; + const head = pn.cohort, hw = Math.min(textWidth(head, FS.title, 600), W * 0.5); + out.push(label(PAD, y + FS.title, head, { size: FS.title, weight: 600, fill: p.ink, maxW: W * 0.5 })); + const detail = fmtInt(pn.patients) + " patients with plasma and tissue · ctDNA detected in " + fmtInt(pn.detected) + " of " + fmtInt(pn.plasmaPatients) + + " with plasma" + (pn.plasmaPatients ? " (" + fmtShare(pn.detected / pn.plasmaPatients, 0) + ")" : ""); + const lines = wrapWidth(detail, W - PAD - hw - 12 - PAD, FS.small); + lines.forEach((l, i) => out.push(label(PAD + hw + 12, y + FS.title + i * (FS.small + 3), l, { size: FS.small, fill: p.muted }))); + y += FS.title + (lines.length - 1) * (FS.small + 3) + 8; + if (pn.ch) { + const c = pn.ch, b = c.before; + const text = "Clonal-haematopoiesis filter removed " + fmtInt(c.removed) + " of " + fmtInt(c.calls) + " plasma mutation calls in " + fmtInt(c.patients) + " patients (" + + fmtInt(c.byGene) + " by gene, " + fmtInt(c.byNormal) + " seen in the matched normal)" + (c.genes.length ? ": " + c.genes.slice(0, 8).map((g) => g.gene + " " + fmtInt(g.calls)).join(", ") + (c.genes.length > 8 ? ", " + fmtInt(c.genes.length - 8) + " more genes" : "") : "") + + " · unfiltered, ctDNA detected in " + fmtInt(b.detected) + (pn.plasmaPatients ? " (" + fmtShare(b.detected / pn.plasmaPatients, 0) + ")" : "") + + (b.survival && b.survival.p != null ? ", OS log-rank " + fmtP(b.survival.p) : ""); + const para = paragraph(text, PAD, y, W - 2 * PAD, p.ink, { size: FS.small }); + out.push(para.svg); + y += para.height + 6; + } + let cx = BR; + for (const c of cols) { cx += GUT; out.push(label(cx + c.w, y + FS.small, c.head, { size: FS.small, fill: p.muted, anchor: "end" })); cx += c.w; } + y += FS.small + 4; + const top = y; + for (const tk of xs.ticks) out.push(``); + pn.genes.forEach((g, gi) => { + const ry = top + gi * ROW, cy = ry + ROW / 2; + if (gi % 2 === 0) out.push(``); + out.push(label(BL - GUT, cy + FS.body * 0.35, g.gene, { size: FS.body, weight: 600, fill: p.ink, anchor: "end" })); + let x = BL; + for (const [n, col, name] of [[g.tissueOnly, TISSUE, "in tissue only"], [g.both, BOTH, "in both"], [g.plasmaOnly, PLASMA, "in plasma only"]] as [number, string, string][]) { + if (n <= 0) continue; + const w = Math.max(1, xs(n) - BL); + out.push(`${esc(g.gene + " — " + fmtInt(n) + (n === 1 ? " patient " : " patients ") + name)}`); + const t = fmtInt(n); + if (textWidth(t, FS.small) + 6 <= w) out.push(label(x + w / 2, cy + FS.small * 0.35, t, { size: FS.small, fill: textOn(col), anchor: "middle" })); + x += w; + } + if (!g.pairs) out.push(label(BL + 2, cy + FS.small * 0.35, "never covered by both panels", { size: FS.small, fill: p.muted })); + let sx = BR; + for (const c of cols) { sx += GUT; out.push(label(sx + c.w, cy + FS.small * 0.35, c.text(g), { size: FS.small, fill: p.muted, anchor: "end" })); sx += c.w; } + }); + y = top + pn.genes.length * ROW; + const axTitle = "patients altered in plasma or tissue"; + out.push(axisBottom(xs, y, p, { title: axTitle, minX: BL - GUT, maxX: BR + GUT, fmt: (t) => fmtInt(t) })); + y += bottomAxisHeight(axTitle) + 8; + const km = kmPanel(pn, W, y, p); + out.push(km.svg); + y += km.height; + }); + const cap = caption(v.caveat || "", W, y + 4, p.muted); + return frame(W, y + 4 + cap.height + 10, out.join("") + cap.svg, p.bg, grown, "ctDNA vs tissue"); +} + +/** overall survival by ctDNA detected or not, or a line saying why it is not drawn */ +function kmPanel(pn: CtdnaPanel, W: number, y0: number, p: ReturnType): { svg: string; height: number } { + if (!pn.survival) { + const t = "Overall survival by ctDNA not drawn: " + (pn.survivalWhy || "no survival data"); + return { svg: label(PAD, y0 + FS.small + 2, t, { size: FS.small, fill: p.muted, maxW: W - 2 * PAD }), height: FS.small + 10 }; + } + const out: string[] = []; + const s = pn.survival; + let y = y0 + 6; + const items = s.curves.map((c, i) => ({ label: c.label + " — " + fmtInt(c.n) + " patients, " + fmtInt(c.events) + " deaths, median " + (c.median == null ? "not reached" : fmtMonths(c.median)), + color: seriesStyle(i).color, dash: seriesStyle(i).dash, kind: "line" as const })); + out.push(label(PAD, y + FS.body, "Overall survival by ctDNA detected", { size: FS.body, weight: 600, fill: p.ink, maxW: W - 2 * PAD })); + y += FS.body + 6; + const lg = legend(items, PAD, y, W - 2 * PAD, p, { size: FS.small }); + out.push(lg.svg); + y += lg.height + 4; + const PLOT = plotHeight(W), T = y + 4, R = 16; + const tMax = Math.max(1, ...s.curves.flatMap((c) => c.points.map((q) => q.t))); + const ys = linear(0, 1, T + PLOT, T, { nice: false }); + ys.ticks = [0, 0.25, 0.5, 0.75, 1]; + const yFmt = (t: number) => fmtShare(t, 0), yTitle = "Survival probability"; + const L = PAD + leftAxisWidth(ys, yTitle, yFmt); + const xs = timeScale(0, tMax, L, W - R); + const xTitle = timeTitle("Months", null); + out.push(axisLeft(ys, L, p, { title: yTitle, gridTo: W - R, fmt: yFmt })); + out.push(axisBottom(xs, T + PLOT, p, { title: xTitle, minX: 0, maxX: W })); + s.curves.forEach((c, i) => { + const st = seriesStyle(i); + out.push(``); + }); + y = T + PLOT + bottomAxisHeight(xTitle) + 4; + if (s.logrank) { + out.push(label(PAD, y + FS.small, "log-rank χ² = " + fmtNum(s.logrank.chi2) + " · " + fmtP(s.logrank.p) + (s.logrank.strata > 1 ? " · stratified by study" : "") + + (s.missing ? " · " + fmtInt(s.missing) + " plasma patients without OS left out" : ""), { size: FS.small, fill: p.muted, maxW: W - 2 * PAD })); + y += FS.small + 6; + } + return { svg: out.join(""), height: y - y0 }; +} + +const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/figures/cutpoint.ts b/lib/figures/cutpoint.ts new file mode 100644 index 0000000..2bbdc8a --- /dev/null +++ b/lib/figures/cutpoint.ts @@ -0,0 +1,83 @@ +// Cut-point figure: the scan's statistic against each candidate cut (the chosen one marked, the +// permutation 95% line dashed), the two groups' Kaplan–Meier curves at it, and per-stratum cuts. +import type { DrawnResultOf } from "../types.ts"; +import { + palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, label, textWidth, + fmtInt, fmtNum, fmtP, fmtRatio, fmtMonths, paragraph, SERIES, FS, SW, r1, type SvgOpts +} from "../svg-kit.ts"; +import { kmPanel } from "./km.ts"; + +const medianText = (m: number | null) => m == null ? "not reached" : fmtMonths(m); + +export function cutpointSvg(v: DrawnResultOf<"cutpoint">, o: SvgOpts): string { + const m = v.cutpoint, p = palette(o.theme); + let W = Math.max(480, o.width || 720), grown = false; + const out: string[] = []; + // the scan + out.push(label(8, FS.title + 2, "Log-rank statistic at each candidate cut of " + m.attribute, { size: FS.title, fill: p.ink, weight: 600, maxW: W - 16 })); + const T = FS.title + 16, PH = Math.round(Math.max(150, Math.min(220, W * 0.22))); + const cs = m.candidates; + const yMax = Math.max(m.maxStat, m.critical95 || 0, 1) * 1.1; + const ys = linear(0, yMax, T + PH, T); + const yTitle = "χ² (standardized)"; + const L = 8 + leftAxisWidth(ys, yTitle), R = W - 16; + const x0 = cs[0].cut, x1 = cs[cs.length - 1].cut; + const xs = linear(x0, x1 > x0 ? x1 : x0 + 1, L, R); + const xTitle = m.attribute + " cut (high is above it)"; + out.push(axisLeft(ys, L, p, { title: yTitle, gridTo: R })); + out.push(axisBottom(xs, T + PH, p, { title: xTitle, minX: 0, maxX: W })); + out.push(``); + if (Number.isFinite(m.critical95)) { + const cy = r1(ys(m.critical95)); + out.push(``); + const t = "permutation 95%: " + fmtNum(m.critical95); + out.push(label(R - 4, cy - 4, t, { size: FS.small, fill: p.muted, anchor: "end" })); + } + const bx = r1(xs(m.cut)), by = r1(ys(m.maxStat)); + out.push(``); + const tag = "cut " + fmtNum(m.cut) + " · χ² " + fmtNum(m.maxStat); + const tw = textWidth(tag, FS.small); + out.push(label(Math.min(R - tw, Math.max(L + 2, bx + 8)), Math.max(T + FS.small, by - 8), tag, { size: FS.small, fill: p.ink, + extra: `stroke="${p.bg}" stroke-width="3" stroke-linejoin="round" paint-order="stroke"` })); + let y = T + PH + bottomAxisHeight(xTitle) + 8; + const lines = ["best cut " + m.attribute + " > " + fmtNum(m.cut) + " (" + fmtInt(m.high.n) + " high, " + fmtInt(m.low.n) + " low): permutation-corrected " + fmtP(m.permP) + + " (" + fmtInt(m.permutations) + " permutations, seed " + m.seed + ") · naive log-rank " + fmtP(m.naiveP) + " — the naive p ignores the search", + m.hr ? "high vs low HR " + fmtRatio(m.hr.hr) + " [95% CI " + fmtRatio(m.hr.ciLow) + "–" + fmtRatio(m.hr.ciHigh) + "]" : ""].filter(Boolean); + for (const s of lines) { const para = paragraph(s, 8, y, W - 16, p.ink); out.push(para.svg); y += para.height; } + y += 10; + // the curves at the cut + const km = kmPanel([ + { name: "low (≤ " + fmtNum(m.cut) + ")", points: m.curves.low.points, atRisk: m.curves.low.atRisk, detail: "median " + medianText(m.low.median) + " · n = " + fmtInt(m.low.n) + " · " + fmtInt(m.low.events) + " events" }, + { name: "high (> " + fmtNum(m.cut) + ")", points: m.curves.high.points, atRisk: m.curves.high.atRisk, detail: "median " + medianText(m.high.median) + " · n = " + fmtInt(m.high.n) + " · " + fmtInt(m.high.events) + " events" }, + ], { y0: y, W, p, xTitle: "Months (" + m.endpoint + ")", yTitle: "Survival probability", title: m.endpoint + " by the chosen cut" }); + out.push(km.svg); + y += km.height + 8; + // per stratum + if (m.strata && m.strata.rows.length) { + out.push(label(8, y + FS.title, "A cut per " + m.strata.attribute, { size: FS.title, fill: p.ink, weight: 600, maxW: W - 16 })); + y += FS.title + 10; + const heads = [m.strata.attribute, "n · events", "cut", "high", "HR [95% CI]", "corrected p", "naive p"]; + const rows = m.strata.rows.map((s) => [s.stratum, fmtInt(s.n) + " · " + fmtInt(s.events), fmtNum(s.cut), fmtInt(s.high.n), + s.hr ? fmtRatio(s.hr.hr) + " [" + fmtRatio(s.hr.ciLow) + "–" + fmtRatio(s.hr.ciHigh) + "]" : "—", fmtP(s.permP), fmtP(s.naiveP)]); + const colW = heads.map((h, k) => Math.max(textWidth(h, FS.small), ...rows.map((r) => textWidth(r[k], FS.body)))); + const CG = 16, nameW = Math.min(colW[0], Math.max(120, W * 0.3)); + const need = 8 + nameW + colW.slice(1).reduce((a, w) => a + w + CG, 0) + CG + 8; + if (need > W) { W = Math.ceil(need); grown = true; } + const xOf = [8]; + for (let k = 1; k < heads.length; k++) xOf.push(xOf[k - 1] + (k === 1 ? nameW : colW[k - 1]) + CG); + heads.forEach((h, k) => out.push(label(xOf[k], y + FS.small, h, { size: FS.small, fill: p.muted }))); + y += FS.small + 4; + const ROW = 20; + rows.forEach((r, i) => { + if (i % 2 === 0) out.push(``); + const base = y + ROW / 2 + FS.body * 0.35, sig = m.strata!.rows[i].permP < 0.05; + r.forEach((c, k) => out.push(label(xOf[k], base, c, { size: FS.body, fill: p.ink, maxW: k === 0 ? nameW : undefined, weight: k === 5 && sig ? 600 : 400 }))); + y += ROW; + }); + y += 6; + } + const text = fmtInt(m.n) + " patients with a value, " + fmtInt(m.events) + " events; " + fmtInt(m.noValue) + " with a survival time but no value." + + (m.strata ? " " + m.strata.omitted.values + " " + m.strata.attribute + " values (" + fmtInt(m.strata.omitted.patients) + " patients) not scanned." : "") + " " + (v.caveat || ""); + const cap = caption(text, W, y + 4, p.muted); + return frame(W, y + 4 + cap.height + 8, out.join("") + cap.svg, p.bg, grown, "Cut-point — " + m.attribute + " on " + m.endpoint); +} diff --git a/lib/figures/drivernegative.ts b/lib/figures/drivernegative.ts new file mode 100644 index 0000000..b7c8975 --- /dev/null +++ b/lib/figures/drivernegative.ts @@ -0,0 +1,73 @@ +// Driver-negative figure: the cohort split into driver-positive, driver-negative and not fully +// profiled as one bar, then the volcano of every other gene between the two groups and its top genes. +import type { DrawnResultOf } from "../types.ts"; +import { volcano, volcanoPlotH, type VolcanoPoint } from "./volcano.ts"; +import { palette, frame, caption, label, textWidth, wrapWidth, seriesStyle, textOn, fmtInt, fmtPct, pDigits, FS, SW, OTHER, type SvgOpts } from "../svg-kit.ts"; + +const PAD = 8, TABLE_ROWS = 12; +const NEG = seriesStyle(0).color, POS = seriesStyle(1).color; +const niceCeil = (x: number) => { const k = 10 ** Math.floor(Math.log10(x)); return [1, 2, 5, 10].map((m) => m * k).find((v) => v >= x - 1e-12)!; }; + +export function drivernegativeSvg(v: DrawnResultOf<"drivernegative">, o: SvgOpts): string { + const p = palette(o.theme), r = v.drivernegative, want = o.width || 936, W = Math.max(520, want); + const out: string[] = []; + const pct = (k: number, d: number) => d ? fmtPct(100 * k / d) : "–"; + // the split, one bar over the sequenced patients + const parts = [["Driver-negative", r.negative, NEG], ["Driver-positive", r.positive, POS], ["Not fully profiled", r.unprofiled, OTHER]] as const; + const head = fmtInt(r.sequenced) + " patients sequenced · drivers: " + r.drivers.join(", "); + const hl = wrapWidth(head, W - 2 * PAD, FS.body); + hl.forEach((l, i) => out.push(label(PAD, 14 + i * Math.round(FS.body * 1.4), l, { size: FS.body, fill: p.muted }))); + let y = 14 + hl.length * Math.round(FS.body * 1.4) + 6; + let x = PAD; + const BW = W - 2 * PAD; + for (const [name, k, color] of parts) { + if (!k) continue; + const w = BW * k / r.sequenced, text = name + " " + fmtInt(k) + " (" + pct(k, r.sequenced) + ")"; + out.push(``); + if (w > textWidth(text, FS.small) + 8) out.push(label(x + 4, y + 14, text, { size: FS.small, fill: textOn(color) })); + x += w; + } + y += 26; + const legendText = parts.map(([name, k]) => name + " " + fmtInt(k) + " (" + pct(k, r.sequenced) + ")").join(" · "); + wrapWidth(legendText, W - 2 * PAD, FS.small).forEach((l) => { y += Math.round(FS.small * 1.35); out.push(label(PAD, y, l, { size: FS.small, fill: p.ink })); }); + y += 14; + + // the volcano: driver-negative to the right + const xOf = (g: { log2Ratio: number | null; altered: [number, number] }, xm: number) => g.log2Ratio == null ? (g.altered[0] ? xm : -xm) : g.log2Ratio; + const xMax = niceCeil(Math.max(1, ...r.rows.map((g) => g.log2Ratio == null ? 0 : Math.abs(g.log2Ratio)))); + const yMax = niceCeil(Math.max(2, ...r.rows.map((g) => -Math.log10(Math.max(1e-300, g.p))))); + const pts: VolcanoPoint[] = r.rows.map((g) => ({ key: g.gene, x: xOf(g, xMax), p: g.p, + color: g.q >= 0.05 ? p.muted : (g.log2Ratio ?? (g.altered[0] ? 1 : -1)) > 0 ? NEG : POS, + tip: g.gene + ": driver-negative " + pct(g.altered[0], g.profiled[0]) + ", driver-positive " + pct(g.altered[1], g.profiled[1]) + " · q " + pDigits(g.q) })); + const vw = Math.min(W, 720); + const vol = volcano(p, { x0: 0, y0: y, width: vw, plotH: volcanoPlotH(vw), grid: { xMax, yMax, nx: 1, ny: 1, cells: [] }, pts, labels: r.labels, + xTitle: "Log2 ratio, driver-negative over driver-positive", sides: ["Driver-negative", "Driver-positive"], r: 3 }); + out.push(vol.svg); + y += vol.height + 18; + + // the genes most enriched in the driver-negative group + const top = r.rows.filter((g) => (g.log2Ratio ?? (g.altered[0] ? 1 : -1)) > 0).slice(0, TABLE_ROWS); + if (top.length) { + out.push(label(PAD, y, "Most enriched in driver-negative patients", { size: FS.body, fill: p.ink, weight: 600 })); + y += 8; + const cell = (a: number, n: number) => fmtInt(a) + "/" + fmtInt(n) + " (" + pct(a, n) + ")"; + const cols = [ + { head: "Gene", text: (g: (typeof top)[number]) => g.gene + (g.cytoband ? " " + g.cytoband : ""), end: false }, + { head: "Driver-negative", text: (g: (typeof top)[number]) => cell(g.altered[0], g.profiled[0]), end: true }, + { head: "Driver-positive", text: (g: (typeof top)[number]) => cell(g.altered[1], g.profiled[1]), end: true }, + { head: "p", text: (g: (typeof top)[number]) => pDigits(g.p), end: true }, + { head: "q", text: (g: (typeof top)[number]) => pDigits(g.q), end: true }, + ].map((c) => ({ ...c, w: Math.ceil(Math.max(textWidth(c.head, FS.body, 600), ...top.map((g) => textWidth(c.text(g), FS.body)))) })); + let cx = PAD; + const at = cols.map((c) => { const a = c.end ? cx + c.w : cx; cx += c.w + 14; return a; }); + y += FS.body + 4; + cols.forEach((c, j) => out.push(label(at[j], y, c.head, { size: FS.body, fill: p.ink, weight: 600, anchor: c.end ? "end" : "start" }))); + out.push(``); + top.forEach((g) => { y += 18; cols.forEach((c, j) => out.push(label(at[j], y + 4, c.text(g), { size: FS.body, fill: p.ink, anchor: c.end ? "end" : "start", weight: c.head === "q" && g.q < 0.05 ? 600 : 400 }))); }); + y += 16; + } + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, W > want, "Driver-negative analysis — " + r.drivers.length + " drivers"); +} + +const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/figures/exclusivity.ts b/lib/figures/exclusivity.ts new file mode 100644 index 0000000..426214f --- /dev/null +++ b/lib/figures/exclusivity.ts @@ -0,0 +1,97 @@ +// Exclusivity figure: a gene × gene triangle, each pair coloured by the direction DISCOVER finds and +// shaded by its q, then the most significant pairs set against the plain Fisher test. +import { + esc, palette, frame, caption, legend, label, textWidth, fmtInt, fmtNum, pDigits, textOn, FS, SW, SERIES, type SvgOpts +} from "../svg-kit.ts"; +import type { DrawnResultOf, ExclusivityPair } from "../types.ts"; + +const PAD = 8, GUT = 12, ROW = 18, TABLE_MAX = 12; + +const direction = (x: ExclusivityPair): "exclusive" | "co-occurring" => x.qExclusive <= x.qCooccur ? "exclusive" : "co-occurring"; +const qOf = (x: ExclusivityPair) => Math.min(x.qExclusive, x.qCooccur); + +export function exclusivitySvg(v: DrawnResultOf<"exclusivity">, o: SvgOpts): string { + const p = palette(o.theme), r = v.exclusivity; + const EXCL = SERIES[0], COOC = SERIES[1]; + let W = Math.max(420, o.width || 960); + const n = r.genes.length; + const labW = Math.max(...r.genes.map((g) => textWidth(g, FS.body, 600))) + GUT; + const cell = Math.max(18, Math.min(46, Math.floor((Math.min(W, 760) - 2 * PAD - labW) / Math.max(1, n - 1)))); + const byPair = new Map(r.pairs.map((x) => [x.a + "|" + x.b, x])); + const pairOf = (a: string, b: string) => byPair.get(a + "|" + b) || byPair.get(b + "|" + a); + + // the table's columns, measured, so a narrow figure grows rather than overprints + const rows = r.pairs.slice(0, TABLE_MAX); + const cols: { head: string; text: (x: ExclusivityPair) => string; w: number; start?: boolean }[] = [ + { head: "pair", text: (x) => x.a + " / " + x.b, w: 0, start: true }, + { head: "both", text: (x) => fmtInt(x.both), w: 0 }, + { head: "expected", text: (x) => fmtNum(x.expected), w: 0 }, + { head: "profiled", text: (x) => fmtInt(x.profiled), w: 0 }, + { head: "DISCOVER q", text: (x) => pDigits(qOf(x)) + " " + direction(x), w: 0, start: true }, + { head: "Fisher q", text: (x) => pDigits(x.fisherQ) + ((x.log2Odds ?? 0) < 0 ? " exclusive" : (x.log2Odds ?? 0) > 0 ? " co-occurring" : ""), w: 0, start: true } + ]; + cols.forEach((c, k) => { c.w = Math.max(textWidth(c.head, FS.small), ...rows.map((x) => textWidth(c.text(x), FS.small, k === 4 ? 600 : 400))); }); + const tableW = 2 * PAD + cols.reduce((s, c) => s + c.w + GUT, 0); + const triW = 2 * PAD + labW + (n - 1) * cell; + const grown = Math.max(tableW, triW) > W; + if (grown) W = Math.ceil(Math.max(tableW, triW)); + + const out: string[] = []; + let y = 4; + const lg = legend([{ label: "mutually exclusive", color: EXCL }, { label: "co-occurring", color: COOC }], PAD, y, W - 2 * PAD, p, { size: FS.small }); + out.push(lg.svg); + y += lg.height + 2; + out.push(label(PAD, y + FS.small, "darker = smaller DISCOVER q; outlined = q < 0.05; the number is the q", { size: FS.small, fill: p.muted, maxW: W - 2 * PAD })); + y += FS.small + 8; + + // lower triangle: row i (genes 1…n−1) against column j < i + const x0 = PAD + labW, top = y; + for (let i = 1; i < n; i++) { + const cy = top + (i - 1) * cell; + out.push(label(x0 - GUT / 2, cy + cell / 2 + FS.body * 0.35, r.genes[i], { size: FS.body, weight: 600, fill: p.ink, anchor: "end" })); + for (let j = 0; j < i; j++) { + const cx = x0 + j * cell, x = pairOf(r.genes[i], r.genes[j]); + if (!x) { + out.push(`${esc(r.genes[i] + " / " + r.genes[j] + " — never profiled together")}`); + continue; + } + const q = qOf(x), col = direction(x) === "exclusive" ? EXCL : COOC; + const shade = Math.max(0.08, Math.min(1, -Math.log10(Math.max(q, 1e-300)) / 6)); + const tip = x.a + " / " + x.b + " — " + fmtInt(x.both) + " altered in both of " + fmtInt(x.profiled) + " profiled, " + fmtNum(x.expected) + + " expected from each tumour's burden; DISCOVER exclusivity q " + pDigits(x.qExclusive) + ", co-occurrence q " + pDigits(x.qCooccur) + "; Fisher q " + pDigits(x.fisherQ); + out.push(`${esc(tip)}`); + const t = pDigits(q); + if (textWidth(t, FS.small) + 4 <= cell - 2) out.push(label(cx + cell / 2, cy + cell / 2 + FS.small * 0.35, t, { size: FS.small, fill: shade > 0.5 ? textOn(col) : p.ink, anchor: "middle" })); + } + } + // column labels under the triangle, turned when the cells are narrower than the names + const ly = top + (n - 1) * cell + 4; + const widest = Math.max(...r.genes.slice(0, n - 1).map((g) => textWidth(g, FS.body, 600))); + const turn = widest + 4 > cell; + for (let j = 0; j < n - 1; j++) { + const cx = x0 + j * cell + cell / 2; + out.push(turn + ? `${esc(r.genes[j])}` + : label(cx, ly + FS.body, r.genes[j], { size: FS.body, weight: 600, fill: p.ink, anchor: "middle" })); + } + y = ly + (turn ? widest : FS.body) + 14; + + // the most significant pairs, DISCOVER beside Fisher + out.push(label(PAD, y + FS.body, "Most significant pairs (" + fmtInt(Math.min(TABLE_MAX, r.pairs.length)) + " of " + fmtInt(r.pairs.length) + ")", { size: FS.body, weight: 600, fill: p.ink })); + y += FS.body + 6; + let cx = PAD; + const xs: number[] = []; + for (const c of cols) { xs.push(c.start ? cx : cx + c.w); cx += c.w + GUT; } + cols.forEach((c, k) => out.push(label(xs[k], y + FS.small, c.head, { size: FS.small, fill: p.muted, anchor: c.start ? "start" : "end" }))); + y += FS.small + 4; + rows.forEach((x, i) => { + if (i % 2 === 0) out.push(``); + const sig = qOf(x) < 0.05; + cols.forEach((c, k) => out.push(label(xs[k], y + ROW / 2 + FS.small * 0.35, c.text(x), { size: FS.small, fill: sig || k === 0 ? p.ink : p.muted, anchor: c.start ? "start" : "end", weight: k === 4 && sig ? 600 : 400 }))); + y += ROW; + }); + y += 8; + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 10, out.join("") + cap.svg, p.bg, grown, "Mutual exclusivity (DISCOVER) — " + r.cohort); +} diff --git a/lib/figures/forest.ts b/lib/figures/forest.ts index 2377103..7d26f63 100644 --- a/lib/figures/forest.ts +++ b/lib/figures/forest.ts @@ -5,7 +5,7 @@ // glance. The scale is set by the estimates, not by the widest interval: a covariate that barely // converged can have an upper bound in the millions, which would otherwise squash every other row // to a dot. An interval running past the scale ends in an arrow, and the caption says so. -import type { DrawnResultOf } from "../types.ts"; +import type { DrawnResultOf, ForestPooled } from "../types.ts"; import { palette, frame, caption, log, axisBottom, bottomAxisHeight, label, textWidth, fmtRatio, pDigits, FS, SW, SERIES, type SvgOpts @@ -95,3 +95,154 @@ export function logisticSvg(v: DrawnResultOf<"logistic">, o: SvgOpts): string { const panels: Panel[] = [{ heading: null, coefs: (v.logistic?.coefficients || []).map((cf) => ({ ...cf, ratio: cf.or })) }]; return forestSvg(panels, Math.max(520, o.width || 760), o.theme, "OR", v.caveat || "", "Logistic regression — odds ratios"); } + +/** One row of a two-marker forest: a label, two ratios with their intervals (either may be missing), and a test's p (and q). */ +export interface DualForestRow { + label: string; detail?: string; + a: { ratio: number; ciLow: number; ciHigh: number } | null; + b: { ratio: number; ciLow: number; ciHigh: number } | null; + p: number | null; q?: number | null; + /** why the row has no estimates */ + note?: string; +} + +/** + * A forest with two markers per row — the same effect in two groups (a above b, each its own + * series colour) on one log axis — and a test column on the right (p, and q where given), e.g. a + * gene's hazard ratio among the treated and the untreated with the p of their interaction. + */ +export function dualForestSvg(rows: DualForestRow[], o: { width?: number; theme?: SvgOpts["theme"]; ratioName: string; names: [string, string]; + testName: string; note: string; title: string }): string { + const W = Math.max(520, o.width || 760), p = palette(o.theme); + const RR = 30, val = (e: DualForestRow["a"]) => e ? fmtRatio(e.ratio) + " [" + fmtRatio(e.ciLow) + "–" + fmtRatio(e.ciHigh) + "]" : "—"; + const pText = (r: DualForestRow) => r.p == null ? (r.note ? "" : "—") : pDigits(r.p) + (r.q != null ? " · q " + pDigits(r.q) : ""); + const labW = Math.min(W * 0.26, Math.max(textWidth(o.ratioName, FS.small), ...rows.map((r) => Math.max(textWidth(r.label, FS.body, 600), r.detail ? textWidth(r.detail, FS.small) : 0)))); + const valW = Math.max(...o.names.map((n) => textWidth(n, FS.small)), ...rows.flatMap((r) => [textWidth(val(r.a), FS.small), textWidth(val(r.b), FS.small)])); + const pW = Math.max(textWidth(o.testName, FS.small), ...rows.map((r) => textWidth(pText(r), FS.body))); + const L = 8 + labW + 14, R = W - 8 - pW - 14 - valW - 14; + const ests = rows.flatMap((r) => [r.a, r.b]).filter((e): e is NonNullable => !!e && Number.isFinite(e.ratio) && e.ratio > 0); + const eLo = Math.min(1, ...ests.map((e) => e.ratio)), eHi = Math.max(1, ...ests.map((e) => e.ratio)); + const lims = [eLo / 4, eHi * 4]; + const ci = ests.flatMap((e) => [e.ciLow, e.ciHigh]).filter((x) => Number.isFinite(x) && x > 0).map((x) => Math.min(lims[1], Math.max(lims[0], x))); + const xs = log(Math.min(eLo, ...ci) * 0.9, Math.max(eHi, ...ci) * 1.1, L, R, { nice: false }); + const [d0, d1] = xs.domain; + const out: string[] = []; + // the two groups' key, then the column heads + let kx = L; + o.names.forEach((n, k) => { + out.push(``); + out.push(label(kx + 12, 13, n, { size: FS.small, fill: p.ink })); + kx += 12 + textWidth(n, FS.small) + 24; + }); + const HEAD = 34; + out.push(label(R + 14, HEAD - 6, o.ratioName + " [95% CI]", { size: FS.small, fill: p.muted })); + out.push(label(W - 8, HEAD - 6, o.testName, { size: FS.small, fill: p.muted, anchor: "end" })); + let y = HEAD, offScale = 0; + rows.forEach((r, i) => { + if (i % 2 === 0) out.push(``); + out.push(label(8, y + 13, r.label, { size: FS.body, fill: p.ink, weight: 600, maxW: labW })); + if (r.detail) out.push(label(8, y + 25, r.detail, { size: FS.small, fill: p.muted, maxW: labW })); + if (r.note && !r.a && !r.b) out.push(label(L, y + RR / 2 + FS.small * 0.35, r.note, { size: FS.small, fill: p.muted, maxW: W - 8 - L })); + ([r.a, r.b] as const).forEach((e, k) => { + const cy = y + (k === 0 ? 10 : 21), col = SERIES[k === 0 ? 0 : 4]; + if (!e) return; + const lo = Math.max(e.ciLow, d0), hi = Math.min(e.ciHigh, d1); + if (Number.isFinite(lo) && Number.isFinite(hi) && hi > lo) + out.push(``); + for (const [past, at, dir] of [[e.ciLow < d0, xs(d0), -1], [e.ciHigh > d1, xs(d1), 1]] as [boolean, number, number][]) { + if (!past) continue; + offScale++; + out.push(``); + } + if (e.ratio > 0 && Number.isFinite(e.ratio)) out.push(``); + out.push(label(R + 14, cy + FS.small * 0.35, val(e), { size: FS.small, fill: p.ink })); + }); + const sig = r.p != null && r.p < 0.05; + out.push(label(W - 8, y + RR / 2 + FS.body * 0.35, pText(r), { size: FS.body, fill: sig ? p.ink : p.muted, anchor: "end", weight: sig ? 600 : 400 })); + y += RR; + }); + out.push(``); + const axTitle = o.ratioName + " (log scale) — left of 1 is lower, right is higher"; + out.push(axisBottom(xs, y + 4, p, { title: axTitle, minX: L - 20, maxX: R + 20 })); + y += 4 + bottomAxisHeight(axTitle) + 6; + const cap = caption(o.note + (offScale ? " " + offScale + " interval end" + (offScale === 1 ? " runs" : "s run") + + " past the axis and " + (offScale === 1 ? "is" : "are") + " drawn as an arrow; the numbers beside it are exact." : ""), W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, o.title); +} + +/** + * The forest view: one row per stratum (n, events, HR [CI], its random-effects weight, the square + * sized by that weight), then the fixed- and random-effects pools as diamonds. + */ +export function metaForestSvg(v: DrawnResultOf<"forest">, o: SvgOpts): string { + const f = v.forest, W = Math.max(520, o.width || 760), p = palette(o.theme); + const pools: [string, ForestPooled][] = []; + if (f.random) pools.push(["Random effects (DerSimonian–Laird)", f.random]); + if (f.fixed) pools.push(["Fixed effect", f.fixed]); + const value = (x: { hr: number; ciLow: number; ciHigh: number }) => fmtRatio(x.hr) + " [" + fmtRatio(x.ciLow) + "–" + fmtRatio(x.ciHigh) + "]"; + const count = (r: { n: number; events: number }) => r.n.toLocaleString("en-US") + " (" + r.events.toLocaleString("en-US") + ")"; + const pct = (w: number) => (100 * w).toFixed(1) + "%"; + const labW = Math.min(W * 0.3, Math.max(textWidth(f.strata, FS.small), ...f.rows.map((r) => textWidth(r.label, FS.body)), + ...pools.map(([l]) => textWidth(l, FS.body, 600) * 0.75))); + const cntW = Math.max(textWidth("n (events)", FS.small), ...f.rows.map((r) => textWidth(count(r), FS.body))); + const valW = Math.max(textWidth("HR [95% CI]", FS.small), ...f.rows.map((x) => textWidth(value(x), FS.body)), ...pools.map(([, x]) => textWidth(value(x), FS.body, 600))); + const wW = pools.length ? Math.max(textWidth("weight / p", FS.small), textWidth("100.0%", FS.body), ...pools.map(([, x]) => textWidth(pDigits(x.p), FS.body))) : 0; + const cntR = 8 + labW + 12 + cntW, L = cntR + 16; + const wR = W - 8, valL = wR - (wW ? wW + 14 : 0) - valW, R = valL - 14; + const all = [...f.rows, ...pools.map(([, x]) => x)]; + const est = all.map((c) => c.hr).filter((r) => Number.isFinite(r) && r > 0); + const eLo = Math.min(1, ...est), eHi = Math.max(1, ...est); + const ci = all.flatMap((c) => [c.ciLow, c.ciHigh]).filter((r) => Number.isFinite(r) && r > 0).map((r) => Math.min(eHi * 4, Math.max(eLo / 4, r))); + const xs = log(Math.min(eLo, ...ci) * 0.9, Math.max(eHi, ...ci) * 1.1, L, R, { nice: false }); + const [d0, d1] = xs.domain; + const at = (r: number) => r1(xs(Math.min(d1, Math.max(d0, r)))); + let offScale = 0; + const out: string[] = []; + out.push(label(8, 14, f.strata, { size: FS.small, fill: p.muted })); + out.push(label(cntR, 14, "n (events)", { size: FS.small, fill: p.muted, anchor: "end" })); + out.push(label(valL, 14, "HR [95% CI]", { size: FS.small, fill: p.muted })); + if (wW) out.push(label(wR, 14, "weight / p", { size: FS.small, fill: p.muted, anchor: "end" })); + let y = HEAD; + const top = y, maxW = Math.max(...f.rows.map((r) => r.weight), 1e-9); + const interval = (lo: number, hi: number, cy: number) => { + const a = Math.max(lo, d0), b = Math.min(hi, d1); + if (b > a) out.push(``); + for (const [past, x, dir] of [[lo < d0, xs(d0), -1], [hi > d1, xs(d1), 1]] as [boolean, number, number][]) { + if (!past) continue; + offScale++; + out.push(``); + } + }; + f.rows.forEach((r, i) => { + const cy = y + ROW / 2, ty = cy + FS.body * 0.35; + if (i % 2 === 0) out.push(``); + out.push(label(8, ty, r.label, { size: FS.body, fill: p.ink, maxW: labW })); + out.push(label(cntR, ty, count(r), { size: FS.body, fill: p.ink, anchor: "end" })); + interval(r.ciLow, r.ciHigh, cy); + const s = pools.length ? 4 + 5 * Math.sqrt(r.weight / maxW) : 8; + out.push(``); + out.push(label(valL, ty, value(r), { size: FS.body, fill: p.ink })); + if (wW) out.push(label(wR, ty, pct(r.weight), { size: FS.body, fill: p.muted, anchor: "end" })); + y += ROW; + }); + if (pools.length) { + out.push(``); + y += 8; + for (const [name, x] of pools) { + const cy = y + ROW / 2, ty = cy + FS.body * 0.35; + out.push(label(8, ty, name, { size: FS.body, fill: p.ink, weight: 600, maxW: cntR - 8 })); + const a = at(x.ciLow), b = at(x.ciHigh), m = at(x.hr); + out.push(``); + out.push(label(valL, ty, value(x), { size: FS.body, fill: p.ink, weight: 600 })); + out.push(label(wR, ty, pDigits(x.p), { size: FS.body, fill: x.p < 0.05 ? p.ink : p.muted, anchor: "end" })); + y += ROW; + } + } + out.push(``); + const axTitle = "HR for " + f.exposedLabel + " (log scale) — right of 1 is worse " + f.endpoint; + out.push(axisBottom(xs, y + 4, p, { title: axTitle, minX: L - 20, maxX: R + 20 })); + y += 4 + bottomAxisHeight(axTitle) + 6; + const cap = caption((pools.length ? "Rows' last column is their weight in the random-effects pool; the pooled rows' is their p. " : "") + (v.caveat || "") + (offScale ? " " + offScale + " interval end" + (offScale === 1 ? " runs" : "s run") + " past the axis and " + + (offScale === 1 ? "is" : "are") + " drawn as an arrow; the numbers beside it are exact." : ""), W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, "Forest plot — " + f.exposedLabel + " by " + f.strata); +} diff --git a/lib/figures/germline.ts b/lib/figures/germline.ts new file mode 100644 index 0000000..28caf1b --- /dev/null +++ b/lib/figures/germline.ts @@ -0,0 +1,66 @@ +// Germline figure: per cohort, one row per gene — its carriers among the patients sequenced, and two +// bars, the carriers' somatic second-hit rate against the non-carriers' somatic rate — then OS by +// carrier status for one gene. +import type { DrawnResultOf, GermlineGene } from "../types.ts"; +import { palette, frame, caption, label, legend, textWidth, linear, axisBottom, bottomAxisHeight, seriesStyle, fmtInt, fmtPct, fmtNum, fmtP, pDigits, num, FS, SW, type SvgOpts } from "../svg-kit.ts"; +import { kmPanel } from "./km.ts"; + +const PAD = 8, ROW = 30, BAR = 9; +const CAR = seriesStyle(0).color, NON = seriesStyle(1).color; +const pct = (k: number, n: number) => n ? fmtPct(100 * k / n) : "–"; + +export function germlineSvg(v: DrawnResultOf<"germline">, o: SvgOpts): string { + const r = v.germline, p = palette(o.theme), want = o.width || 936, W = Math.max(440, want); + const out: string[] = []; + let y = 0; + const lg = legend([{ label: "Carriers: somatic second hit (mutation or deep deletion)", color: CAR, kind: "rect" }, { label: "Non-carriers: somatic mutation or deep deletion", color: NON, kind: "rect" }], PAD, y + 4, W - 2 * PAD, p); + out.push(lg.svg); + y += lg.height + 10; + const geneW = Math.max(...r.panels.flatMap((pn) => pn.genes.map((g) => textWidth(g.gene, FS.body, 600)))) + 8; + const carrierText = (g: GermlineGene) => fmtInt(g.carriers) + " of " + fmtInt(g.sequenced) + " (" + pct(g.carriers, g.sequenced) + ")"; + const carW = Math.max(textWidth("Carriers / sequenced", FS.small, 600), ...r.panels.flatMap((pn) => pn.genes.map((g) => textWidth(carrierText(g), FS.small)))) + 20; + const qW = textWidth("q 0.0001", FS.small) + 10; + const x0 = PAD + geneW + carW, x1 = W - PAD - qW - 70; + for (const pn of r.panels) { + out.push(label(PAD, y + FS.title, pn.cohort + " · " + fmtInt(pn.patients) + " patients", { size: FS.title, fill: p.ink, weight: 600, maxW: W - 2 * PAD })); + y += FS.title + 10; + const shown = pn.genes.filter((g) => g.sequenced > 0); + const max = Math.max(0.1, ...shown.flatMap((g) => [g.carriers ? g.carrierEither / g.carriers : 0, g.noncarriers ? g.noncarrierEither / g.noncarriers : 0])); + const xs = linear(0, Math.min(1, max * 1.1), x0, x1); + out.push(label(PAD + geneW, y + FS.small, "Carriers / sequenced", { size: FS.small, fill: p.muted, weight: 600 })); + out.push(label(x0, y + FS.small, "Second hit, % of carriers / non-carriers", { size: FS.small, fill: p.muted, weight: 600, maxW: x1 - x0 })); + y += FS.small + 6; + for (const g of shown) { + const cy = y + ROW / 2; + out.push(label(PAD, cy + FS.body * 0.35, g.gene, { size: FS.body, fill: p.ink, weight: 600 })); + out.push(label(PAD + geneW, cy + FS.small * 0.35, carrierText(g), { size: FS.small, fill: p.ink })); + const sides: [number, number, string, string][] = [[g.carrierEither, g.carriers, CAR, "carriers"], [g.noncarrierEither, g.noncarriers, NON, "non-carriers"]]; + sides.forEach(([k, n, color, who], i) => { + const by = cy - BAR - 1 + i * (BAR + 2); + if (!n) return; + const w = xs(k / n) - x0; + out.push(`${g.gene} ${who}: ${fmtInt(k)} of ${fmtInt(n)} (${pct(k, n)})`); + out.push(label(x0 + w + 4, by + BAR - 1, pct(k, n) + (i === 0 ? " (" + fmtInt(k) + ")" : ""), { size: FS.small, fill: p.ink })); + }); + if (g.q != null) out.push(label(W - PAD, cy + FS.small * 0.35, "q " + pDigits(g.q), { size: FS.small, fill: p.ink, anchor: "end", weight: g.q < 0.05 ? 600 : 400 })); + y += ROW; + } + out.push(``); + out.push(axisBottom(xs, y + 2, p, { title: "Share with a somatic hit in the gene", fmt: (t) => Math.round(t * 100) + "%" })); + y += bottomAxisHeight("x") + 10; + const unseq = pn.genes.filter((g) => !g.sequenced).map((g) => g.gene); + if (unseq.length) { out.push(label(PAD, y + FS.small, "Not drawn (no patient sequenced for it): " + unseq.join(", "), { size: FS.small, fill: p.muted, maxW: W - 2 * PAD })); y += FS.small + 8; } + } + for (const s of r.survival || []) { + y += 6; + const km = kmPanel(s.curves.filter((c) => c.n > 0).map((c) => ({ name: c.group, points: c.points, atRisk: c.atRisk, + detail: "n = " + fmtInt(c.n) + " · " + fmtInt(c.events) + " deaths · median " + (c.median == null ? "not reached" : fmtNum(Math.round(c.median * 10) / 10) + " mo") })), + { y0: y, W, p, xTitle: "Months (OS_MONTHS)", yTitle: "Overall survival", title: "Overall survival by " + s.gene + " germline status — " + s.cohort }); + out.push(km.svg); + y += km.height + 4; + const line = s.logrank ? "Log-rank χ² = " + fmtNum(s.logrank.chi2) + " (" + s.logrank.df + " df) · " + fmtP(s.logrank.p) : s.noTest || ""; + if (line) { out.push(label(PAD, y + FS.body, line, { size: FS.body, fill: p.ink, maxW: W - 2 * PAD })); y += Math.round(FS.body * 1.8); } + } + const cap = caption("LOH: " + r.loh + ". " + (v.caveat || ""), W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, W > want, "Germline carriers and second hits — " + r.genes.length + " genes"); +} diff --git a/lib/figures/hla.ts b/lib/figures/hla.ts new file mode 100644 index 0000000..86d0879 --- /dev/null +++ b/lib/figures/hla.ts @@ -0,0 +1,91 @@ +// HLA figure: per cohort, the share of fully typed patients homozygous at 0, 1 or 2+ class I loci +// and the share with HLA LOH, as 100% bars; then the endpoint by homozygosity and by HLA LOH. +import type { DrawnResultOf, HlaSplit, HlaHr } from "../types.ts"; +import { + esc, palette, frame, caption, label, legend, paragraph, textWidth, textOn, fmtInt, fmtShare, fmtP, fmtRatio, fmtMonths, FS, SERIES, OTHER, r1, type SvgOpts +} from "../svg-kit.ts"; +import { kmPanel } from "./km.ts"; + +const PAD = 8, BAR = 16, ROWH = 24, GAP = 28; +const HOM_COLORS = [SERIES[0], SERIES[1], SERIES[2]], LOSS = SERIES[5]; + +const hrText = (name: string, h: HlaHr | null) => h ? name + " HR " + fmtRatio(h.hr) + " [95% CI " + fmtRatio(h.ciLow) + "–" + fmtRatio(h.ciHigh) + "], " + fmtP(h.p) + " (n = " + fmtInt(h.n) + ")" : name + " HR not estimable"; + +export function hlaSvg(v: DrawnResultOf<"hla">, o: SvgOpts): string { + const p = palette(o.theme), r = v.hla; + const W = Math.max(480, o.width || 936); + const out: string[] = []; + let y = 4; + const lg = legend([ + { label: "homozygous at 0 loci", color: HOM_COLORS[0] }, { label: "1 locus", color: HOM_COLORS[1] }, { label: "2–3 loci", color: HOM_COLORS[2] }, + { label: "HLA LOH", color: LOSS }, { label: "no HLA LOH", color: OTHER }], PAD, y, W - 2 * PAD, p, { size: FS.small }); + out.push(lg.svg); + y += lg.height + 8; + const rowNames = ["class I homozygosity", "HLA LOH"]; + const nameW = Math.max(...rowNames.map((s) => textWidth(s, FS.small))); + const BL = PAD + nameW + 14, BR = W - PAD; + const bar = (yy: number, parts: { n: number; color: string; what: string }[], total: number, rowName: string, empty: string) => { + out.push(label(PAD, yy + BAR / 2 + FS.small * 0.35, rowName, { size: FS.small, fill: p.muted })); + if (!total) { out.push(label(BL, yy + BAR / 2 + FS.small * 0.35, empty, { size: FS.small, fill: p.muted, maxW: BR - BL })); return; } + let x = BL; + for (const part of parts) { + if (part.n <= 0) continue; + const w = (BR - BL) * part.n / total; + out.push(`${esc(fmtInt(part.n) + " of " + fmtInt(total) + " " + part.what)}`); + const t = fmtShare(part.n / total, 1); + if (textWidth(t, FS.small) + 6 <= w) out.push(label(x + w / 2, yy + BAR / 2 + FS.small * 0.35, t, { size: FS.small, fill: textOn(part.color), anchor: "middle" })); + x += w; + } + }; + r.panels.forEach((pn, pi) => { + if (pi) y += 18; + out.push(label(PAD, y + FS.title, pn.cohort, { size: FS.title, weight: 600, fill: p.ink, maxW: W - 2 * PAD })); + y += FS.title + 4; + const detail = fmtInt(pn.typed) + " of " + fmtInt(pn.patients) + " patients typed at HLA-A, -B and -C · homozygous " + + pn.loci.map((l) => "HLA-" + l.locus + " " + (l.typed ? fmtShare(l.homozygous / l.typed, 1) : "–")).join(", ") + + " · HLA LOH called in " + fmtInt(pn.lohEvaluated) + " patients"; + const para = paragraph(detail, PAD, y, W - 2 * PAD, p.muted, { size: FS.small }); + out.push(para.svg); + y += para.height + 4; + bar(y, [{ n: pn.homozygous[0], color: HOM_COLORS[0], what: "typed patients heterozygous at all three loci" }, + { n: pn.homozygous[1], color: HOM_COLORS[1], what: "typed patients homozygous at one locus" }, + { n: pn.homozygous[2] + pn.homozygous[3], color: HOM_COLORS[2], what: "typed patients homozygous at two or three loci" }], pn.typed, rowNames[0], "no patient typed at all three loci"); + y += ROWH; + bar(y, [{ n: pn.loh, color: LOSS, what: "patients with an HLA LOH call who have a loss" }, + { n: pn.lohEvaluated - pn.loh, color: OTHER, what: "patients with an HLA LOH call who have none" }], pn.lohEvaluated, rowNames[1], "no sample has an HLA LOH call"); + y += ROWH + 6; + const side = W >= 760; + const KW = side ? Math.floor((W - GAP) / 2) : W; + const a = splitPanel(pn.byHomozygosity, pn.byHomozygosityWhy, r.endpoint + " by homozygosity", KW, r.endpoint, r.covariates, p); + const b = splitPanel(pn.byLoh, pn.byLohWhy, r.endpoint + " by HLA LOH", KW, r.endpoint, r.covariates, p); + if (side) { + out.push(`${a.svg}${b.svg}`); + y += Math.max(a.height, b.height); + } else { + out.push(`${a.svg}`); + y += a.height + 8; + out.push(`${b.svg}`); + y += b.height; + } + }); + const cap = caption(v.caveat || "", W, y + 4, p.muted); + return frame(W, y + 4 + cap.height + 10, out.join("") + cap.svg, p.bg, false, "HLA class I genotype"); +} + +/** one split's KM panel at (0, 0) with its log-rank and hazard ratios below, or a line saying why it is not drawn */ +function splitPanel(s: HlaSplit | null, why: string | undefined, title: string, W: number, endpoint: string, covariates: string[], p: ReturnType): + { svg: string; height: number } { + if (!s) { + const para = paragraph(title + " not drawn: " + (why || "no data"), PAD, 0, W - 2 * PAD, p.muted, { size: FS.small }); + return { svg: para.svg, height: para.height + 6 }; + } + const km = kmPanel(s.curves.map((c) => ({ name: c.label, points: c.points, atRisk: c.atRisk, + detail: "median " + (c.median == null ? "not reached" : fmtMonths(c.median)) + " · n = " + fmtInt(c.n) + " · " + fmtInt(c.events) + " events" })), + { y0: 0, W, p, xTitle: "Months (" + endpoint + ")", yTitle: "Survival probability", title }); + const out = [km.svg]; + let y = km.height + 2; + const lines = [(s.logrank ? "log-rank " + fmtP(s.logrank.p) + (s.logrank.strata > 1 ? " (stratified by study)" : "") + " · " : "") + hrText(s.curves[0].label + " vs not:", s.hr), + ...(covariates.length ? [hrText("adjusted for " + covariates.join(", ") + ":", s.adjusted)] : [])]; + for (const l of lines) { const para = paragraph(l, PAD, y, W - 2 * PAD, p.ink, { size: FS.small }); out.push(para.svg); y += para.height; } + return { svg: out.join(""), height: y + 4 }; +} diff --git a/lib/figures/index.ts b/lib/figures/index.ts index 00ea1cb..102eaf3 100644 --- a/lib/figures/index.ts +++ b/lib/figures/index.ts @@ -18,6 +18,23 @@ import { enrichmentSvg } from "./enrichment.ts"; import { assaysSvg } from "./assays.ts"; import { compositionSvg } from "./composition.ts"; import { correlatesSvg } from "./correlates.ts"; +import { acquiredSvg } from "./acquired.ts"; +import { ctdnaSvg } from "./ctdna.ts"; +import { exclusivitySvg } from "./exclusivity.ts"; +import { txendpointsSvg } from "./txendpoints.ts"; +import { multistateSvg } from "./multistate.ts"; +import { rmstSvg } from "./rmst.ts"; +import { predictiveSvg } from "./predictive.ts"; +import { cutpointSvg } from "./cutpoint.ts"; +import { hlaSvg } from "./hla.ts"; +import { metaForestSvg } from "./forest.ts"; +import { adjustedSvg } from "./adjusted.ts"; +import { instabilitySvg } from "./instability.ts"; +import { actionabilitySvg } from "./actionability.ts"; +import { drivernegativeSvg } from "./drivernegative.ts"; +import { organotropismSvg } from "./organotropism.ts"; +import { molsubtypeSvg } from "./molsubtype.ts"; +import { germlineSvg } from "./germline.ts"; /** a kind's figure, drawn only when its result has something to draw, and its one-line title */ interface Figure { draw(v: DrawnResultOf, o: SvgOpts): string | null; title(v: ViewResultOf): string } @@ -80,10 +97,78 @@ const FIGURES: { [K in ViewKind]?: Figure } = { draw: (v, o) => v.composition && v.composition.groups.some((g) => g.n > 0) ? compositionSvg(v, o) : null, title: (v) => "Composition" + (v.composition ? " — " + v.composition.sourceName : "") }, + forest: { + draw: (v, o) => v.forest.rows.length ? metaForestSvg(v, o) : null, + title: (v) => "Forest plot" + (v.forest ? " — " + v.forest.exposedLabel + " by " + v.forest.strata + ", " + v.forest.endpoint : "") + }, + adjusted: { + draw: (v, o) => v.adjusted.rows.length ? adjustedSvg(v, o) : null, + title: (v) => "Adjusted enrichment" + (v.adjusted ? " — " + v.adjusted.groups.join(" vs ") + (v.adjusted.covariates.length ? ", adjusted for " + v.adjusted.covariates.join(", ") : "") : "") + }, + actionability: { + draw: (v, o) => v.actionability.panels.some((pn) => pn.sequenced) ? actionabilitySvg(v, o) : null, + title: (v) => "Clinical actionability" + (v.actionability ? " — OncoKB " + v.actionability.pin + (v.actionability.groupBy ? " by " + v.actionability.groupBy : "") : "") + }, + drivernegative: { + draw: (v, o) => v.drivernegative.sequenced ? drivernegativeSvg(v, o) : null, + title: (v) => "Driver-negative analysis" + (v.drivernegative ? " — " + v.drivernegative.alteration + ", driver-negative vs driver-positive" : "") + }, + organotropism: { + draw: (v, o) => v.organotropism.sites.length && v.organotropism.genes.length ? organotropismSvg(v, o) : null, + title: (v) => "Organotropism" + (v.organotropism ? " — " + v.organotropism.genes.length + " genes × " + v.organotropism.sites.length + " sites, " + v.organotropism.source : "") + }, + molsubtype: { + draw: (v, o) => v.molsubtype.panels.some((pn) => pn.classified) ? molsubtypeSvg(v, o) : null, + title: (v) => "Molecular subtypes" + (v.molsubtype ? " — " + v.molsubtype.classifier + (v.molsubtype.by ? " by " + v.molsubtype.by : "") : "") + }, + germline: { + draw: (v, o) => v.germline.panels.some((pn) => pn.genes.some((g) => g.sequenced)) ? germlineSvg(v, o) : null, + title: (v) => "Germline carriers and second hits" + (v.germline ? " — " + v.germline.genes.length + " genes" : "") + }, correlates: { draw: (v, o) => v.correlates && v.correlates.tested > 0 ? correlatesSvg(v, o) : null, title: (v) => "Co-expression" + (v.correlates ? " — " + v.correlates.gene + " against every gene" : "") }, + acquired: { + draw: (v, o) => v.acquired && v.acquired.panels.some((pn) => pn.patients && pn.genes.length) ? acquiredSvg(v, o) : null, + title: (v) => "Acquired under therapy" + (v.acquired ? " — samples before and after " + v.acquired.anchoredTo : "") + }, + ctdna: { + draw: (v, o) => v.ctdna && v.ctdna.panels.some((pn) => pn.patients) ? ctdnaSvg(v, o) : null, + title: () => "ctDNA vs tissue" + }, + exclusivity: { + draw: (v, o) => v.exclusivity && v.exclusivity.pairs.length ? exclusivitySvg(v, o) : null, + title: (v) => "Mutual exclusivity (DISCOVER)" + (v.exclusivity ? " — " + v.exclusivity.cohort : "") + }, + hla: { + draw: (v, o) => v.hla.panels.some((pn) => pn.typed || pn.lohEvaluated) ? hlaSvg(v, o) : null, + title: (v) => "HLA class I genotype and " + (v.hla ? v.hla.endpoint : "OS") + }, + cutpoint: { + draw: (v, o) => v.cutpoint.candidates.length ? cutpointSvg(v, o) : null, + title: (v) => "Cut-point" + (v.cutpoint ? " — " + v.cutpoint.attribute + " on " + v.cutpoint.endpoint : "") + }, + predictive: { + draw: (v, o) => v.predictive.genes.length ? predictiveSvg(v, o) : null, + title: (v) => "Predictive biomarkers" + (v.predictive ? " — gene × " + v.predictive.exposure : "") + }, + rmst: { + draw: (v, o) => v.rmst.panels.some((pn) => pn.curves.some((c) => c.n)) ? rmstSvg(v, o) : null, + title: (v) => "Restricted mean survival time" + (v.rmst ? " — " + v.rmst.endpointLabel : "") + }, + multistate: { + draw: (v, o) => v.multistate.cohorts.some((c) => c.n) ? multistateSvg(v, o) : null, + title: () => "Multistate model — progression and death" + }, + txendpoints: { + draw: (v, o) => v.txendpoints.curves.some((c) => c.n) ? txendpointsSvg(v, o) : null, + title: (v) => "Treatment endpoints" + (v.txendpoints ? " — " + v.txendpoints.endpointLabel + " from " + v.txendpoints.anchoredTo : "") + }, + instability: { + draw: (v, o) => v.instability && v.instability.panels.some((pn) => pn.score.some((n) => n > 0)) ? instabilitySvg(v, o) : null, + title: () => "Genomic instability — aneuploidy score and FGA" + }, }; /** the figure for a view of one of these kinds, or null (another kind, or nothing to draw) */ diff --git a/lib/figures/instability.ts b/lib/figures/instability.ts new file mode 100644 index 0000000..60d5935 --- /dev/null +++ b/lib/figures/instability.ts @@ -0,0 +1,97 @@ +// Instability figure: per cohort, the aneuploidy score and FGA as horizontal boxes from their +// histograms, then each arm's gain and loss frequency as an arm × cohort heatmap. +import { + esc, palette, frame, caption, linear, axisBottom, bottomAxisHeight, legend, label, textWidth, fmtInt, fmtNum, fmtP, seriesStyle, FS, SW, type SvgOpts +} from "../svg-kit.ts"; +import { CN_GAIN, CN_LOSS } from "./common.ts"; +import type { DrawnResultOf, InstabilitySpread } from "../types.ts"; + +const PAD = 8, GUT = 12, BOX_ROW = 22, BOX_H = 12, MIN_CELL = 13, STRIP = 12; + +export function instabilitySvg(v: DrawnResultOf<"instability">, o: SvgOpts): string { + const p = palette(o.theme), r = v.instability; + let W = Math.max(480, o.width || 960); + const cohorts = r.panels.map((pn) => pn.cohort); + const labW = Math.min(Math.max(...cohorts.map((c) => textWidth(c, FS.body, 600)), textWidth("gain", FS.small)) + GUT, 200); + const nA = r.arms.length; + const need = PAD + labW + GUT + nA * MIN_CELL + PAD; + const grown = need > W; + if (grown) W = Math.ceil(need); + const out: string[] = []; + let y = 4; + + // the two box panels, side by side when there is room, stacked when not + const sideBySide = W >= 720; + const panelW = sideBySide ? (W - 2 * PAD - GUT) / 2 : W - 2 * PAD; + const boxPanel = (x0: number, y0: number, title: string, max: number, get: (i: number) => InstabilitySpread, unit: string) => { + const parts: string[] = []; + parts.push(label(x0, y0 + FS.body, title, { size: FS.body, weight: 600, fill: p.ink, maxW: panelW })); + const top = y0 + FS.body + 8, L = x0 + labW, R = x0 + panelW - GUT; + const xs = linear(0, max, L, R); + for (const t of xs.ticks) parts.push(``); + cohorts.forEach((c, i) => { + const s = get(i), cy = top + i * BOX_ROW + BOX_ROW / 2, col = seriesStyle(i).color; + parts.push(label(L - GUT / 2, cy + FS.body * 0.35, c, { size: FS.body, weight: 600, fill: p.ink, anchor: "end", maxW: labW - GUT })); + if (s.median == null) { parts.push(label(L + 2, cy + FS.small * 0.35, "no segmented sample", { size: FS.small, fill: p.muted })); return; } + const tip = c + " — median " + fmtNum(s.median) + unit + ", IQR " + fmtNum(s.q1!) + "–" + fmtNum(s.q3!) + ", 5th–95th percentile " + fmtNum(s.p5!) + "–" + fmtNum(s.p95!); + parts.push(``); + parts.push(`${esc(tip)}`); + parts.push(``); + }); + const yA = top + cohorts.length * BOX_ROW; + parts.push(axisBottom(xs, yA, p, { title: title.replace(/ \(.*/, ""), minX: x0, maxX: x0 + panelW, fmt: (t) => fmtNum(t) })); + return { svg: parts.join(""), height: yA + bottomAxisHeight(title) - y0 }; + }; + const maxScore = Math.max(1, ...r.panels.map((pn) => pn.scoreSpread.p95 ?? 0), 5); + const maxFga = Math.min(100, Math.max(10, ...r.panels.map((pn) => pn.fgaSpread.p95 ?? 0))); + const b1 = boxPanel(PAD, y, "Aneuploidy score (arms altered, of " + nA + ")", Math.ceil(maxScore), (i) => r.panels[i].scoreSpread, " arms"); + const b2 = boxPanel(sideBySide ? PAD + panelW + GUT : PAD, sideBySide ? y : y + b1.height + 8, "Fraction of genome altered (%)", Math.ceil(maxFga), (i) => r.panels[i].fgaSpread, "%"); + out.push(b1.svg, b2.svg); + y += sideBySide ? Math.max(b1.height, b2.height) : b1.height + 8 + b2.height; + if (r.test) { + out.push(label(PAD, y + FS.small + 2, r.test.name + ": aneuploidy score " + fmtP(r.test.scoreP) + " · FGA " + fmtP(r.test.fgaP), { size: FS.small, fill: p.muted, maxW: W - 2 * PAD })); + y += FS.small + 8; + } + y += 10; + + // arm × cohort heatmap: each cohort two strips, gain above loss, shade = share of samples measured + const maxShare = Math.max(0.05, ...r.panels.flatMap((pn) => pn.arms.map((a) => a.measured ? Math.max(a.gain, a.loss) / a.measured : 0))); + out.push(label(PAD, y + FS.body, "Arm-level gains and losses — share of the samples measuring each arm, darkest at " + Math.round(100 * maxShare) + "%", { size: FS.body, weight: 600, fill: p.ink, maxW: W - 2 * PAD })); + y += FS.body + 6; + const lg = legend([{ label: "gain", color: CN_GAIN }, { label: "loss", color: CN_LOSS }], PAD, y, W - 2 * PAD, p, { size: FS.small }); + out.push(lg.svg); + y += lg.height + 4; + const L = PAD + labW, cell = (W - PAD - L) / nA; + r.panels.forEach((pn, i) => { + const ry = y + i * (2 * STRIP + 6); + out.push(label(L - GUT / 2, ry + STRIP + FS.body * 0.35, pn.cohort, { size: FS.body, weight: 600, fill: p.ink, anchor: "end", maxW: labW - GUT })); + pn.arms.forEach((a, k) => { + const x = L + k * cell; + for (const [n, col, dy, what] of [[a.gain, CN_GAIN, 0, "gained"], [a.loss, CN_LOSS, STRIP, "lost"]] as [number, string, number, string][]) { + const share = a.measured ? n / a.measured : 0; + out.push(`${esc(pn.cohort + " — " + a.arm + " " + what + " in " + fmtInt(n) + " of " + fmtInt(a.measured) + " samples measuring it (" + (a.measured ? Math.round(100 * share) : 0) + "%)")}`); + } + }); + }); + y += r.panels.length * (2 * STRIP + 6) + 2; + // arm labels: every arm when they fit, otherwise each chromosome's name once + const armFits = Math.max(...r.arms.map((a) => textWidth(a, FS.small))) + 2 <= cell; + if (armFits) r.arms.forEach((a, k) => out.push(label(L + k * cell + cell / 2, y + FS.small, a, { size: FS.small, fill: p.muted, anchor: "middle" }))); + else { + let lastEnd = -Infinity; + for (let k = 0; k < nA;) { + const chrom = r.arms[k].slice(0, -1); + let span = 1; + while (k + span < nA && r.arms[k + span].slice(0, -1) === chrom) span++; + const cx = L + (k + span / 2) * cell, half = textWidth(chrom, FS.small) / 2; + if (cx - half > lastEnd + 2) { out.push(label(cx, y + FS.small, chrom, { size: FS.small, fill: p.muted, anchor: "middle" })); lastEnd = cx + half; } + k += span; + } + } + y += FS.small + 4 + 8; + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 10, out.join("") + cap.svg, p.bg, grown, "Genomic instability — aneuploidy and FGA"); +} + +const r1 = (n: number) => Math.round(n * 10) / 10; diff --git a/lib/figures/km.ts b/lib/figures/km.ts new file mode 100644 index 0000000..004856a --- /dev/null +++ b/lib/figures/km.ts @@ -0,0 +1,86 @@ +// A Kaplan–Meier panel several figures draw: step curves, one per group, the number still at risk +// under each tick, and a legend with each group's detail. Positioned at (0, y0) and `W` wide; +// returns its drawing and height, so a figure stacks it with its own parts. `origin` is where the +// curves start at time 0: 1 for survival, 0 for a cumulative incidence drawn on the same panel. +import type { KmPoint } from "../types.ts"; +import { stepPath, r1 } from "../chart/marks.ts"; +import { plotHeight, type Pal } from "./common.ts"; +import { + linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, timeScale, + seriesStyle, fmtInt, fmtNum, label, textWidth, esc, FS, SW, +} from "../svg-kit.ts"; + +export interface KmPanelCurve { + name: string; points: KmPoint[]; + /** the number at risk at each whole month from 0 */ + atRisk?: number[]; + /** the legend's detail line (median, n, events) */ + detail: string; + /** the series colour to draw it in (default its place in the list) */ + series?: number; +} + +/** `shadeTo`: the area under each curve from 0 to that time is shaded (a restricted mean), with a rule at it */ +export function kmPanel(curves: KmPanelCurve[], o: { y0: number; W: number; p: Pal; xTitle: string; yTitle?: string; tMax?: number; title?: string; shadeTo?: number; origin?: number }): + { svg: string; height: number } { + const { y0, W, p } = o; + const out: string[] = []; + let T = y0 + 12; + if (o.title) { out.push(label(8, y0 + FS.title, o.title, { size: FS.title, fill: p.ink, weight: 600, maxW: W - 16 })); T += FS.title + 6; } + const R = 16, PLOT = plotHeight(W); + const yTitle = o.yTitle || "Probability"; + const ys = linear(0, 1, T + PLOT, T, { nice: false }); + ys.ticks = [0, 0.25, 0.5, 0.75, 1]; + const yFmt = (t: number) => Math.round(t * 100) + "%"; + const risk = curves.length > 0 && curves.every((c) => Array.isArray(c.atRisk)); + const nameW = risk ? Math.min(W * 0.14, 180, Math.max(...curves.map((c) => textWidth(c.name, FS.small)))) : 0; + const L = Math.max(8 + leftAxisWidth(ys, yTitle, yFmt), risk ? 8 + 20 + nameW + 10 : 0); + const tMax = o.tMax ?? Math.max(1, ...curves.flatMap((c) => c.points.map((q) => q.t)), ...curves.map((c) => (c.atRisk?.length || 1) - 1)); + const widest = risk ? Math.max(...curves.map((c) => textWidth(fmtInt(c.atRisk![0] || 0), FS.small))) : 0; + const xs = timeScale(0, tMax, L, W - R, { minGap: widest }); + out.push(axisLeft(ys, L, p, { title: yTitle, gridTo: W - R, fmt: yFmt })); + out.push(axisBottom(xs, T + PLOT, p, { title: o.xTitle, minX: 0, maxX: W, fmt: (t) => fmtNum(t) })); + if (o.shadeTo != null) { + const tau = Math.min(o.shadeTo, tMax); + curves.forEach((c, i) => { + const line = stepPath(c.points.filter((q) => q.t <= tau), xs, ys, { t: 0, s: 1 }, tau); + out.push(``); + }); + out.push(``); + const tl = "τ = " + fmtNum(tau); + const tx = xs(tau) + 4 + textWidth(tl, FS.small) <= W - R ? xs(tau) + 4 : xs(tau) - 4 - textWidth(tl, FS.small); + out.push(label(tx, T + FS.small, tl, { size: FS.small, fill: p.muted })); + } + curves.forEach((c, i) => { + const st = seriesStyle(c.series ?? i); + out.push(``); + }); + let y = T + PLOT + bottomAxisHeight(o.xTitle) + 10; + if (risk) { + const ROW = Math.round(FS.small * 1.6); + const at = (c: KmPanelCurve, t: number) => { const a = c.atRisk!; const m = Math.round(t); return m < a.length ? a[m] : 0; }; + const wid = Math.max(...curves.flatMap((c) => xs.ticks.map((t) => textWidth(fmtInt(at(c, t)), FS.small)))); + const gap = xs.ticks.length > 1 ? xs(xs.ticks[1]) - xs(xs.ticks[0]) : Infinity; + const every = Math.max(1, Math.ceil((wid + 6) / gap)); + out.push(label(8, y + FS.small, "Number at risk", { size: FS.small, fill: p.muted, weight: 600 })); + curves.forEach((c, i) => { + const st = seriesStyle(c.series ?? i), cy = y + ROW * (i + 1) + ROW / 2, ty = cy + FS.small * 0.35; + out.push(``); + out.push(label(28, ty, c.name, { size: FS.small, fill: p.ink, maxW: nameW })); + xs.ticks.forEach((t, k) => { + if (k % every) return; + const text = fmtInt(at(c, t)), w = textWidth(text, FS.small), x = xs(t); + const left = Math.min(Math.max(x - w / 2, L - 4), W - 4 - w); + out.push(`` + + `${esc(c.name + ": " + text + " at risk at " + fmtNum(t) + " months")}${text}`); + }); + }); + y += ROW * (curves.length + 1) + 8; + } + const lg = legend(curves.map((c, i) => ({ label: c.name, color: seriesStyle(c.series ?? i).color, dash: seriesStyle(c.series ?? i).dash, kind: "line" as const, detail: c.detail })), + 8, y, W - 16, p, { grid: true }); + out.push(lg.svg); + y += lg.height + 4; + return { svg: out.join(""), height: y - y0 }; +} diff --git a/lib/figures/molsubtype.ts b/lib/figures/molsubtype.ts new file mode 100644 index 0000000..ed7e72b --- /dev/null +++ b/lib/figures/molsubtype.ts @@ -0,0 +1,65 @@ +// Molecular-subtype figure: each cohort's classified patients as one stacked bar of subtype shares, +// and beneath it one bar per group of the `by` attribute; then overall survival by subtype. +import type { DrawnResultOf, MolsubtypeCounts } from "../types.ts"; +import { palette, frame, caption, label, legend, textWidth, seriesStyle, textOn, fmtInt, fmtPct, fmtNum, fmtP, num, FS, type SvgOpts } from "../svg-kit.ts"; +import { kmPanel } from "./km.ts"; + +const PAD = 8, BAR_H = 18, GAP = 6; +const NAMES: Record = { POLEmut: "POLE-ultramutated", MMRd: "MMR-deficient", p53abn: "p53-abnormal", NSMP: "NSMP" }; +const nameOf = (s: string) => NAMES[s] || s; + +export function molsubtypeSvg(v: DrawnResultOf<"molsubtype">, o: SvgOpts): string { + const r = v.molsubtype, p = palette(o.theme), want = o.width || 936, W = Math.max(400, want); + const out: string[] = []; + let y = 0; + const lg = legend(r.subtypes.map((s, i) => ({ label: nameOf(s), color: seriesStyle(i).color, kind: "rect" as const })), PAD, y + 4, W - 2 * PAD, p); + out.push(lg.svg); + y += lg.height + 10; + const rowLabels = r.panels.flatMap((pn) => [pn.cohort, ...(pn.groups || []).map((g) => g.label)]); + const labelW = Math.min(W * 0.28, Math.max(...rowLabels.map((l) => textWidth(l, FS.body))) + 12); + const countW = textWidth("n = 00,000 · 0,000 not classifiable", FS.small) + 10; + const showCount = W - 2 * PAD - labelW - countW > 200; + const barX = PAD + labelW, barW = W - PAD - barX - (showCount ? countW : 0); + const bar = (name: string, c: MolsubtypeCounts, bold: boolean) => { + out.push(label(barX - 6, y + BAR_H / 2 + FS.body * 0.35, name, { size: FS.body, fill: p.ink, anchor: "end", weight: bold ? 600 : 400, maxW: labelW - 8 })); + let x = barX; + c.counts.forEach((k, i) => { + if (!k || !c.classified) return; + const w = barW * k / c.classified, color = seriesStyle(i).color, share = fmtPct(100 * k / c.classified, 0); + out.push(`${name}: ${nameOf(r.subtypes[i])} ${fmtInt(k)} of ${fmtInt(c.classified)} classified (${fmtPct(100 * k / c.classified)})`); + if (w > textWidth(share, FS.small) + 6) out.push(label(x + w / 2, y + BAR_H / 2 + FS.small * 0.35, share, { size: FS.small, fill: textOn(color), anchor: "middle" })); + x += w; + }); + if (showCount) out.push(label(barX + barW + 6, y + BAR_H / 2 + FS.small * 0.35, "n = " + fmtInt(c.classified) + (c.notClassifiable ? " · " + fmtInt(c.notClassifiable) + " not classifiable" : ""), { size: FS.small, fill: p.muted, maxW: countW - 6 })); + y += BAR_H + GAP; + }; + for (const pn of r.panels) { + bar(pn.cohort, pn, true); + if (pn.groups?.length) { + out.push(label(PAD, y + FS.small, "by " + r.by + (pn.byTest ? " · χ² " + fmtNum(pn.byTest.chi2) + " (" + pn.byTest.df + " df), " + fmtP(pn.byTest.p) : ""), { size: FS.small, fill: p.muted, maxW: W - 2 * PAD })); + y += FS.small + 6; + for (const g of pn.groups) bar(g.label, g, false); + } + y += 6; + } + if (r.cohortTest) { out.push(label(PAD, y + FS.body, "Between cohorts: χ² " + fmtNum(r.cohortTest.chi2) + " (" + r.cohortTest.df + " df), " + fmtP(r.cohortTest.p), { size: FS.body, fill: p.ink, maxW: W - 2 * PAD })); y += Math.round(FS.body * 1.8); } + if (!showCount) { + const counts = r.panels.map((pn) => pn.cohort + ": " + fmtInt(pn.classified) + " classified, " + fmtInt(pn.notClassifiable) + " not classifiable").join(" · "); + const c = caption(counts, W, y, p.ink); + out.push(c.svg); y += c.height + 4; + } + + for (const s of r.survival || []) { + y += 8; + const idx = (sub: string) => r.subtypes.indexOf(sub); + const km = kmPanel(s.curves.map((c) => ({ name: nameOf(c.subtype), series: idx(c.subtype), points: c.points, atRisk: c.atRisk, + detail: "n = " + fmtInt(c.n) + " · " + fmtInt(c.events) + " deaths · median " + (c.median == null ? "not reached" : fmtNum(Math.round(c.median * 10) / 10) + " mo") })), + { y0: y, W, p, xTitle: "Months (OS_MONTHS)", yTitle: "Overall survival", title: "Overall survival by subtype — " + s.cohort }); + out.push(km.svg); + y += km.height + 4; + const line = s.logrank ? s.logrank.groups + "-group log-rank χ² = " + fmtNum(s.logrank.chi2) + " (" + s.logrank.df + " df) · " + fmtP(s.logrank.p) + (s.logrank.strata > 1 ? " · stratified by study" : "") : s.noTest || ""; + if (line) { out.push(label(PAD, y + FS.body, line, { size: FS.body, fill: p.ink, maxW: W - 2 * PAD })); y += Math.round(FS.body * 1.8); } + } + const cap = caption((r.noSurvival ? "No survival panel: " + r.noSurvival + ". " : "") + (v.caveat || ""), W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, W > want, "Molecular subtypes — " + r.classifierLabel); +} diff --git a/lib/figures/multistate.ts b/lib/figures/multistate.ts new file mode 100644 index 0000000..4e7c966 --- /dev/null +++ b/lib/figures/multistate.ts @@ -0,0 +1,168 @@ +// Multistate figure: per cohort the share of patients in each state over time (Aalen–Johansen, +// stacked), the Nelson–Aalen cumulative hazard of each transition with one line per cohort, and a +// table of each transition's events, rate and hazard ratio against the first cohort. +import type { DrawnResultOf, MultistateTransitionKey } from "../types.ts"; +import { + palette, frame, caption, linear, axisLeft, axisBottom, leftAxisWidth, bottomAxisHeight, legend, label, textWidth, + seriesStyle, fmtInt, fmtNum, fmtPct, fmtRatio, fmtP, FS, SW, SERIES, OTHER, type SvgOpts, +} from "../svg-kit.ts"; +import { stepPath, r1 } from "../chart/marks.ts"; +import { timeScale } from "../chart/time.ts"; +import { lineSwatch } from "../chart/legend.ts"; + +/** the three states: alive without progression, alive after it, dead */ +export const MS_STATES = [ + { label: "alive, not progressed", color: SERIES[5] }, + { label: "alive, progressed", color: SERIES[1] }, + { label: "dead", color: OTHER }, +] as const; + +export const MS_TRANSITIONS: Record = { + "01": "start → progression", "02": "start → death (no progression)", "12": "progression → death", +}; + +/** the corners of a step function of (t, v) from t = 0 to tEnd */ +function stepCorners(pts: { t: number; v: number }[], x: (t: number) => number, y: (v: number) => number, tEnd: number): [number, number][] { + const out: [number, number][] = [[x(0), y(pts.length ? pts[0].v : 0)]]; + let level = out[0][1], lastX = out[0][0]; + for (const q of pts.slice(1)) { + const nx = x(q.t), ny = y(q.v); + if (nx - lastX < 0.5) out[out.length - 1][1] = ny; + else { out.push([nx, level], [nx, ny]); lastX = nx; } + level = ny; + } + out.push([Math.max(lastX, x(tEnd)), level]); + return out; +} +const poly = (v: [number, number][]) => "M" + v.map(([a, b]) => r1(a) + " " + r1(b)).join("L") + "Z"; + +export const msRateText = (r: { rate: number; lo: number | null; hi: number | null }) => + fmtNum(r.rate, 2) + (r.lo != null && r.hi != null ? " (" + fmtNum(r.lo, 2) + "–" + fmtNum(r.hi, 2) + ")" : ""); + +export function multistateSvg(v: DrawnResultOf<"multistate">, o: SvgOpts): string { + const m = v.multistate, p = palette(o.theme), W = Math.max(420, o.width || 760); + const out: string[] = []; + const xTitle = "Months from " + m.origin; + let y = 4; + + // state occupation: one stacked panel per cohort + out.push(label(8, y + FS.title, "Share of patients in each state (Aalen–Johansen)", { size: FS.title, fill: p.ink, weight: 600, maxW: W - 16 })); + y += FS.title + 6; + const key = legend(MS_STATES.map((s) => ({ label: s.label, color: s.color })), 8, y, W - 16, p, { size: FS.small }); + out.push(key.svg); y += key.height + 2; + const cols = W >= 720 && m.cohorts.length > 1 ? 2 : 1, GAPX = 20; + const pw = (W - 16 - GAPX * (cols - 1)) / cols, ph = Math.round(Math.max(150, Math.min(220, pw * 0.45))); + const panelH = FS.body + 8 + ph + bottomAxisHeight(xTitle) + 6; + m.cohorts.forEach((c, i) => { + const x0 = 8 + (i % cols) * (pw + GAPX), y0 = y + Math.floor(i / cols) * panelH; + const g: string[] = []; + g.push(lineSwatch(0, FS.body * 0.6, seriesStyle(i).color, seriesStyle(i).dash)); + g.push(label(24, FS.body, c.cohort + " · n = " + fmtInt(c.n), { size: FS.body, fill: p.ink, weight: 600, maxW: pw - 24 })); + const T = FS.body + 8; + const ys = linear(0, 100, T + ph, T, { nice: false }); + ys.ticks = [0, 25, 50, 75, 100]; + const yFmt = (t: number) => t + "%"; + const L = leftAxisWidth(ys, undefined, yFmt) + 4; + const xs = timeScale(0, m.tMax, L, pw - 8); + g.push(axisLeft(ys, L, p, { fmt: yFmt })); + g.push(axisBottom(xs, T + ph, p, { title: xTitle, minX: 0, maxX: pw })); + if (c.occupation.length) { + const yv = (f: number) => ys(Math.max(0, Math.min(1, f)) * 100); + const end = c.drawnTo; + const b1 = stepCorners(c.occupation.map((q) => ({ t: q.t, v: q.p0 })), xs, yv, end); + const b2 = stepCorners(c.occupation.map((q) => ({ t: q.t, v: q.p0 + q.p1 })), xs, yv, end); + const base: [number, number][] = [[xs(0), yv(0)], [xs(end), yv(0)]]; + const top: [number, number][] = [[xs(0), yv(1)], [xs(end), yv(1)]]; + g.push(`alive, not progressed`); + g.push(`alive, progressed`); + g.push(`dead`); + } + out.push(`${g.join("")}`); + }); + y += Math.ceil(m.cohorts.length / cols) * panelH + 4; + + // occupation at the horizons + for (const [i, c] of m.cohorts.entries()) { + const text = c.cohort + ": " + c.atHorizon.map((h) => fmtNum(h.months) + " mo " + (h.p0 == null ? "— (fewer than 10 followed)" + : fmtPct(h.p0 * 100, 0) + " / " + fmtPct(h.p1! * 100, 0) + " / " + fmtPct(h.p2! * 100, 0))).join(" · "); + out.push(lineSwatch(8, y + FS.small * 0.6, seriesStyle(i).color, seriesStyle(i).dash)); + out.push(label(32, y + FS.small, text, { size: FS.small, fill: p.muted, maxW: W - 40 })); + y += Math.round(FS.small * 1.6); + } + out.push(label(8, y + FS.small, "(not progressed / progressed / dead)", { size: FS.small, fill: p.muted, maxW: W - 16 })); + y += Math.round(FS.small * 1.6) + 8; + + // cumulative hazards: one panel per transition + out.push(label(8, y + FS.title, "Cumulative hazard of each transition (Nelson–Aalen)", { size: FS.title, fill: p.ink, weight: 600, maxW: W - 16 })); + y += FS.title + 8; + const keys: MultistateTransitionKey[] = ["01", "02", "12"]; + const hcols = W >= 760 ? 3 : 1, hw = (W - 16 - GAPX * (hcols - 1)) / hcols, hh = Math.round(Math.max(130, Math.min(190, hw * 0.55))); + const hPanel = FS.body + 6 + hh + bottomAxisHeight("Months") + 6; + keys.forEach((k, j) => { + const x0 = 8 + (j % hcols) * (hw + GAPX), y0 = y + Math.floor(j / hcols) * hPanel; + const g: string[] = []; + g.push(label(0, FS.body, MS_TRANSITIONS[k], { size: FS.body, fill: p.ink, weight: 600, maxW: hw })); + const T = FS.body + 6; + const peak = Math.max(0.05, ...m.cohorts.flatMap((c) => c.cumhaz[k].map((q) => q.h))); + const ys = linear(0, peak, T + hh, T); + const L = leftAxisWidth(ys) + 4; + const xs = timeScale(0, m.tMax, L, hw - 8); + g.push(axisLeft(ys, L, p, { gridTo: hw - 8 })); + g.push(axisBottom(xs, T + hh, p, { title: "Months", minX: 0, maxX: hw })); + m.cohorts.forEach((c, i) => { + const st = seriesStyle(i), pts = c.cumhaz[k]; + if (pts.length < 2) return; + g.push(``); + }); + out.push(`${g.join("")}`); + }); + y += Math.ceil(keys.length / hcols) * hPanel + 4; + + // the table: per transition and cohort, events / person-months, rate, hazard ratio + const head = ["transition · cohort", "events", "person-months", "per 100 person-months (95% CI)", m.cohorts.length > 1 ? "HR vs " + m.reference : ""]; + const rows: { cells: string[]; color?: number; header?: boolean }[] = []; + for (const k of keys) { + rows.push({ cells: [MS_TRANSITIONS[k]], header: true }); + const cx = m.cox.find((q) => q.key === k); + m.cohorts.forEach((c, i) => { + const r = c.rates.find((q) => q.key === k)!; + const hr = cx?.rows.find((q) => q.cohort === c.cohort); + rows.push({ color: i, cells: [c.cohort, fmtInt(r.events), fmtInt(r.personMonths), msRateText(r), + m.cohorts.length < 2 ? "" : i === 0 ? "reference" : hr ? fmtRatio(hr.hr) + " (" + fmtRatio(hr.lo) + "–" + fmtRatio(hr.hi) + ") " + fmtP(hr.p) : cx?.noFit ? "not fitted" : "–"] }); + }); + if (cx?.noFit) rows.push({ cells: [" no hazard ratio: " + cx.noFit] }); + } + const nameW = Math.min(W * 0.34, Math.max(...rows.filter((r) => !r.header && r.cells.length > 1).map((r) => textWidth(r.cells[0], FS.body) + 22), textWidth(head[0], FS.small))); + const colW = [1, 2, 3, 4].map((ci) => Math.max(textWidth(head[ci], FS.small), ...rows.filter((r) => r.cells.length > 1).map((r) => textWidth(r.cells[ci] || "", FS.body)))); + const GAP = 14; + const fits = 8 + nameW + colW.reduce((a, w) => a + w + GAP, 0) <= W - 8; + const colX: number[] = []; + let cxp = 8 + nameW + GAP; + colW.forEach((w, ci) => { colX.push(cxp); cxp += w + GAP; if (!fits && ci === 1) cxp = 8 + nameW + GAP; }); + const ROW = fits ? 18 : 30; + const cellW = (ci: number) => !fits && ci >= 2 ? (ci === 3 ? W - colX[ci] - 8 : colW[ci]) : colW[ci]; + head.forEach((h, ci) => { + if (!h) return; + const line2 = !fits && ci >= 3; + out.push(label(ci === 0 ? 8 : colX[ci - 1], y + FS.small + (line2 ? FS.small + 4 : 0), h, { size: FS.small, fill: p.muted, maxW: ci === 0 ? nameW : cellW(ci - 1) })); + }); + y += FS.small + 6 + (fits ? 0 : FS.small + 4); + for (const r of rows) { + if (r.header) { out.push(label(8, y + FS.body + 2, r.cells[0], { size: FS.body, fill: p.ink, weight: 600, maxW: W - 16 })); y += 20; continue; } + if (r.cells.length === 1) { out.push(label(8, y + FS.small, r.cells[0].trim(), { size: FS.small, fill: p.muted, maxW: W - 16 })); y += 16; continue; } + const base = y + FS.body; + const st = seriesStyle(r.color!); + out.push(lineSwatch(8, base - FS.body * 0.35, st.color, st.dash)); + out.push(label(30, base, r.cells[0], { size: FS.body, fill: p.ink, maxW: nameW - 22 })); + r.cells.slice(1).forEach((t, ci) => { + if (!t) return; + const line2 = !fits && ci >= 2; + out.push(label(colX[ci], base + (line2 ? 13 : 0), t, { size: line2 ? FS.small : FS.body, fill: ci >= 2 ? p.ink : p.muted, maxW: cellW(ci) })); + }); + y += ROW; + } + y += 6; + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, "Multistate model — " + m.cohorts.map((c) => c.cohort).join(", ")); +} diff --git a/lib/figures/organotropism.ts b/lib/figures/organotropism.ts new file mode 100644 index 0000000..aa15bb5 --- /dev/null +++ b/lib/figures/organotropism.ts @@ -0,0 +1,115 @@ +// Organotropism figure: a gene × site heatmap of the Mantel–Haenszel odds ratio of alteration with +// metastasis to each site (outlined where q < 0.05), the strongest associations as a table, and, +// from dated timeline sites, the cumulative incidence of one site by one gene's status. +import type { DrawnResultOf, OrganotropismCell } from "../types.ts"; +import { palette, frame, caption, label, textWidth, wrapWidth, fmtInt, fmtNum, fmtPct, fmtP, pDigits, esc, num, FS, SW, type SvgOpts } from "../svg-kit.ts"; +import { numericColor } from "../oncoprint-style.ts"; +import { kmPanel } from "./km.ts"; + +const PAD = 8, LOG2_CLAMP = 1, TABLE_ROWS = 10, MIN_CELL = 18, MAX_CELL = 56, HEAD_MAX = 120; + +const cellFill = (log2: number) => numericColor(127.5 + Math.max(-1, Math.min(1, log2 / LOG2_CLAMP)) * 127.5, 127.5); +const pct = (k: number, n: number) => n ? fmtPct(100 * k / n) : "–"; + +export function organotropismSvg(v: DrawnResultOf<"organotropism">, o: SvgOpts): string { + const r = v.organotropism, p = palette(o.theme), want = o.width || 936; + const siteLabel = new Map(r.sites.map((s) => [s.site, s.label])); + const geneW = Math.max(...r.genes.map((g) => textWidth(g.gene, FS.body))) + 10; + const need = 2 * PAD + geneW + r.sites.length * MIN_CELL + 60; + const W = Math.max(want, need, 360); + const out: string[] = []; + let y = 0; + const head = fmtInt(r.patients) + " patients · sites from " + r.sourceLabel + " · " + (r.measure === "driver" ? "OncoKB-driver " : "") + r.alteration + "s" + + (r.stratifyBy ? " · stratified by " + r.stratifyBy : " · not stratified"); + for (const l of wrapWidth(head, W - 2 * PAD, FS.body)) { y += Math.round(FS.body * 1.4); out.push(label(PAD, y, l, { size: FS.body, fill: p.muted })); } + y += 14; + + // the heatmap: genes down, sites across, labels turned above the columns + const cw = Math.max(MIN_CELL, Math.min(MAX_CELL, (W - 2 * PAD - geneW - 60) / r.sites.length)); + const headH = Math.min(HEAD_MAX, Math.max(...r.sites.map((s) => textWidth(s.label, FS.small)))) + 6; + const x0 = PAD + geneW, top = y + headH; + r.sites.forEach((s, j) => { + const cx = x0 + j * cw + cw / 2 + FS.small * 0.35; + out.push(label(cx, top - 4, s.label, { size: FS.small, fill: p.ink, rotate: -90, maxW: HEAD_MAX })); + }); + const cellOf = new Map(r.cells.map((c) => [c.site + "\u0000" + c.gene, c])); + const ch = Math.max(16, Math.min(22, cw)); + r.genes.forEach((g, i) => { + const cy = top + i * ch; + out.push(label(x0 - 6, cy + ch / 2 + FS.body * 0.35, g.gene, { size: FS.body, fill: p.ink, anchor: "end" })); + r.sites.forEach((s, j) => { + const c = cellOf.get(s.site + "\u0000" + g.gene); + const x = x0 + j * cw; + const tip = c ? tipOf(c, s.label) : g.gene + " · " + s.label + ": no patient profiled"; + if (!c || c.p == null) { + out.push(`${esc(tip)}`); + return; + } + const fill = c.log2Or == null ? p.line : cellFill(c.log2Or); + const sig = c.q != null && c.q < 0.05; + out.push(`${esc(tip)}`); + }); + }); + y = top + r.genes.length * ch + 14; + // the colour key + const keys = [-1, -0.5, 0, 0.5, 1]; + let kx = PAD; + out.push(label(kx, y + FS.small * 0.35, "Odds ratio", { size: FS.small, fill: p.muted })); + kx += textWidth("Odds ratio", FS.small) + 8; + for (const k of keys) { + const t = k < 0 ? "1/" + fmtNum(2 ** -k, 2) : fmtNum(2 ** k, 2); + out.push(``); + out.push(label(kx + 18, y + FS.small * 0.35, t, { size: FS.small, fill: p.ink })); + kx += 18 + textWidth(t, FS.small) + 10; + } + const keyNote = "outlined: q < 0.05 · empty: not tested"; + if (kx + textWidth(keyNote, FS.small) < W - PAD) out.push(label(kx + 4, y + FS.small * 0.35, keyNote, { size: FS.small, fill: p.muted })); + else { y += Math.round(FS.small * 1.6); out.push(label(PAD, y + FS.small * 0.35, keyNote, { size: FS.small, fill: p.muted })); } + y += 20; + + // the strongest associations + const top10 = r.cells.filter((c) => c.q != null && c.q < 0.05).sort((a, b) => a.p! - b.p!).slice(0, TABLE_ROWS); + out.push(label(PAD, y, fmtInt(r.tested) + " site × gene cells tested, " + fmtInt(r.significant) + " at q < 0.05" + (top10.length ? "; the strongest:" : ""), { size: FS.body, fill: p.ink, weight: 600, maxW: W - 2 * PAD })); + y += 6; + if (top10.length) { + type C = OrganotropismCell; + const cols = [ + { head: "Site", text: (c: C) => siteLabel.get(c.site) || c.site, end: false }, + { head: "Gene", text: (c: C) => c.gene, end: false }, + { head: "With the site", text: (c: C) => fmtInt(c.withAltered) + "/" + fmtInt(c.withSite) + " (" + pct(c.withAltered, c.withSite) + ")", end: true }, + { head: "Without", text: (c: C) => fmtInt(c.withoutAltered) + "/" + fmtInt(c.without) + " (" + pct(c.withoutAltered, c.without) + ")", end: true }, + { head: "MH OR", text: (c: C) => c.or == null ? "–" : fmtNum(c.or, 2), end: true }, + { head: "q", text: (c: C) => pDigits(c.q!), end: true }, + ].map((c) => ({ ...c, w: Math.ceil(Math.max(textWidth(c.head, FS.body, 600), ...top10.map((x) => textWidth(c.text(x), FS.body)))) })); + const gap = Math.max(8, Math.min(14, (W - 2 * PAD - cols.reduce((a, c) => a + c.w, 0)) / (cols.length - 1))); + let cx = PAD; + const at = cols.map((c) => { const a = c.end ? cx + c.w : cx; cx += c.w + gap; return a; }); + y += FS.body + 4; + cols.forEach((c, j) => out.push(label(at[j], y, c.head, { size: FS.body, fill: p.ink, weight: 600, anchor: c.end ? "end" : "start" }))); + out.push(``); + top10.forEach((c) => { y += 18; cols.forEach((col, j) => out.push(label(at[j], y + 4, col.text(c), { size: FS.body, fill: p.ink, anchor: col.end ? "end" : "start" }))); }); + } + y += 22; + + // the cumulative incidence of one site by one gene + const inc = r.incidence; + if (inc) { + const km = kmPanel(inc.curves.map((c) => { + const h = inc.horizons.map((z) => { const x = z.cells.find((q) => q.cohort === c.cohort); return x ? fmtNum(z.months) + " mo " + fmtPct(100 * x.f1) : ""; }).filter(Boolean).join(", "); + return { name: c.cohort, points: c.points.map((pt) => ({ t: pt.t, s: pt.f1 })), detail: "n = " + fmtInt(c.n) + " · " + fmtInt(c.events) + " with " + inc.site + ", " + fmtInt(c.competing) + " died first · " + h }; + }), { y0: y, W, p, origin: 0, xTitle: "Months from the timeline's day 0", yTitle: "Cumulative incidence", + title: "Cumulative incidence of " + inc.site + " by " + inc.gene + ", death competing" }); + out.push(km.svg); + y += km.height + 4; + if (inc.gray) { out.push(label(PAD, y + FS.body, "Gray's test (permutation, " + inc.gray.permutations + " shuffles) " + fmtP(inc.gray.p), { size: FS.body, fill: p.ink, maxW: W - 2 * PAD })); y += Math.round(FS.body * 1.8); } + } + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, W > want, "Organotropism — " + r.genes.length + " genes × " + r.sites.length + " sites"); +} + +function tipOf(c: OrganotropismCell, site: string): string { + return c.gene + " · " + site + ": altered in " + fmtInt(c.withAltered) + "/" + fmtInt(c.withSite) + " (" + pct(c.withAltered, c.withSite) + ") with the site, " + + fmtInt(c.withoutAltered) + "/" + fmtInt(c.without) + " (" + pct(c.withoutAltered, c.without) + ") without" + + (c.p == null ? " · not tested" : " · MH OR " + (c.or == null ? "undefined" : fmtNum(c.or, 2)) + ", " + fmtP(c.p) + ", " + fmtP(c.q ?? 1, "q") + " over " + c.strata + " strata"); +} diff --git a/lib/figures/predictive.ts b/lib/figures/predictive.ts new file mode 100644 index 0000000..80deb82 --- /dev/null +++ b/lib/figures/predictive.ts @@ -0,0 +1,22 @@ +// Predictive biomarker figure: per gene, its hazard ratio among the exposed and the unexposed on one +// log axis, with the interaction's p and q beside it. +import type { DrawnResultOf } from "../types.ts"; +import { fmtInt, type SvgOpts } from "../svg-kit.ts"; +import { dualForestSvg } from "./forest.ts"; + +export function predictiveSvg(v: DrawnResultOf<"predictive">, o: SvgOpts): string { + const m = v.predictive; + const rows = m.genes.map((g) => ({ + label: g.gene, + detail: "mutated " + fmtInt(g.mutatedExposed) + "/" + fmtInt(g.exposed) + " exposed · " + fmtInt(g.mutatedUnexposed) + "/" + fmtInt(g.unexposed) + " not", + a: g.inExposed ? { ratio: g.inExposed.hr, ciLow: g.inExposed.ciLow, ciHigh: g.inExposed.ciHigh } : null, + b: g.inUnexposed ? { ratio: g.inUnexposed.hr, ciLow: g.inUnexposed.ciLow, ciHigh: g.inUnexposed.ciHigh } : null, + p: g.interaction ? g.interaction.p : null, q: g.q, + ...(g.skipped ? { note: "not fitted: " + g.skipped } : {}), + })); + const note = fmtInt(m.n) + " patients (" + fmtInt(m.exposed) + " exposed to " + m.exposure + "), " + fmtInt(m.events) + " deaths or events on " + m.endpoint + + ". Each row: the mutated against the wild-type among the exposed (upper marker) and the unexposed (lower marker); the right column is the gene × exposure interaction. " + + m.genes.filter((g) => g.interaction).map((g) => g.gene + " interaction HR " + g.interaction!.hr.toFixed(2)).join(", ") + ". " + (v.caveat || ""); + return dualForestSvg(rows, { width: o.width, theme: o.theme, ratioName: "HR, mutated vs wild-type", names: ["exposed: " + m.exposure, "unexposed"], + testName: "interaction p · q", note, title: "Predictive biomarkers — " + m.exposure }); +} diff --git a/lib/figures/riskmodel.ts b/lib/figures/riskmodel.ts index ce195a9..bf7eb31 100644 --- a/lib/figures/riskmodel.ts +++ b/lib/figures/riskmodel.ts @@ -12,6 +12,7 @@ import { lineSwatch } from "../chart/legend.ts"; import { r1 } from "../chart/marks.ts"; import { placeLabels, type LabelWant } from "../chart/place.ts"; import { unplacedNote } from "./volcano.ts"; +import { kmPanel } from "./km.ts"; /** a hazard ratio below 1 and above it take the shared series' blue and vermillion */ const FAVOURABLE = SERIES[0], ADVERSE = SERIES[4]; @@ -308,6 +309,21 @@ export function riskmodelSvg(v: DrawnResultOf<"riskmodel">, o: SvgOpts): string yy += 2 + bottomAxisHeight(gTitle); } + if (m.external) { + const e = m.external; + yy += 18; + const km = kmPanel(e.groups.map((g, i) => ({ name: g.name, points: g.curve.points, atRisk: g.curve.atRisk, + detail: "median " + (g.curve.median == null ? "not reached" : fmtMonths(g.curve.median)) + " · n = " + fmtInt(g.curve.n) + " · " + fmtInt(g.curve.events) + " events" + + (g.hr == null ? (i === 0 ? " · reference" : "") : " · HR " + fmtRatio(g.hr) + " [" + fmtRatio(g.ciLow!) + "–" + fmtRatio(g.ciHigh!) + "]") })), + { y0: yy, W, p, xTitle: timeTitle("Months", m.anchoredTo), yTitle: "Survival probability", + title: "External validation on “" + e.cohort + "” (" + e.study + "), cut at the training thresholds" }); + out.push(km.svg); + yy += km.height; + const vs = ["C-index " + e.cIndex.toFixed(3) + " (95% bootstrap CI " + e.cIndexLo.toFixed(3) + "–" + e.cIndexHi.toFixed(3) + ", " + e.bootstrap + " resamples) on " + fmtInt(e.n) + " patients never trained on" + + (e.logrank ? " · log-rank χ² = " + fmtNum(e.logrank.chi2) + " (" + e.logrank.df + " df) · " + fmtP(e.logrank.p) : "")]; + for (const s2 of vs) { const para = paragraph(s2, 8, yy - 2, W - 16, p.ink); out.push(para.svg); yy += para.height; } + } + const left = [m.notProfiled ? fmtInt(m.notProfiled) + " with no mutation panel on record" : "", m.noStratum ? fmtInt(m.noStratum) + " with no " + m.strata : "", m.noCovariate ? fmtInt(m.noCovariate) + " missing a covariate" : "", m.noAnchor ? fmtInt(m.noAnchor) + " with no " + m.anchoredTo + " event" : "", m.anchorAfterStart ? fmtInt(m.anchorAfterStart) + " whose " + m.anchoredTo + " came after the study's day 0" : "", @@ -323,6 +339,9 @@ export function riskmodelSvg(v: DrawnResultOf<"riskmodel">, o: SvgOpts): string (left.length ? " Left out: " + left.join("; ") + "." : "") + (m.features.length > shown.length ? " " + (m.features.length - shown.length) + " more genes were kept in at least one run; they are in the result, not drawn." : "") + (m.table1 ? " " + table1Words(m.table1) : "") + + (m.external ? " Validation: " + fmtInt(m.external.n) + " of " + fmtInt(m.external.cohortPatients) + " patients of “" + m.external.cohort + "” scored; " + + fmtInt(m.external.notCovered) + " on panels missing one of the model's " + m.external.modelGenes + " weighted genes, " + fmtInt(m.external.notProfiled) + " with no panel" + + (m.external.noAnchor ? ", " + fmtInt(m.external.noAnchor) + " with no " + m.anchoredTo + " event" : "") + " left out." : "") + " " + (v.caveat || ""); const cap = caption(text, W, yy + 4, p.muted); return frame(W, yy + 4 + cap.height + 8, out.join("") + cap.svg, p.bg, grown, diff --git a/lib/figures/rmst.ts b/lib/figures/rmst.ts new file mode 100644 index 0000000..f86e8a2 --- /dev/null +++ b/lib/figures/rmst.ts @@ -0,0 +1,78 @@ +// RMST figure: Kaplan–Meier curves per cohort with the area to τ shaded — one panel per landmark, +// as small multiples — and a table of each cohort's restricted mean with its difference and ratio +// against the first cohort. +import type { DrawnResultOf, RmstComparison } from "../types.ts"; +import { palette, frame, caption, label, textWidth, seriesStyle, fmtInt, fmtNum, fmtRatio, fmtP, FS, type SvgOpts } from "../svg-kit.ts"; +import { lineSwatch } from "../chart/legend.ts"; +import { r1 } from "../chart/marks.ts"; +import { kmPanel } from "./km.ts"; + +const mo = (x: number) => fmtNum(Math.round(x * 10) / 10); +/** a restricted mean and its interval: "17.2 mo (16.1–18.3)" */ +export const rmstText = (c: { rmst: number; lo: number; hi: number }) => mo(c.rmst) + " mo (" + mo(c.lo) + "–" + mo(c.hi) + ")"; +export const rmstDiffText = (c: RmstComparison) => (c.diff >= 0 ? "+" : "") + mo(c.diff) + " mo (" + mo(c.diffLo) + " to " + mo(c.diffHi) + ") " + fmtP(c.diffP); +export const rmstRatioText = (c: RmstComparison) => fmtRatio(c.ratio) + " (" + fmtRatio(c.ratioLo) + "–" + fmtRatio(c.ratioHi) + ")"; +const panelTitle = (L: number, tau: number) => (L > 0 ? "From the " + fmtNum(L) + "-month landmark" : "From the start") + " · RMST to τ = " + fmtNum(tau) + " months"; + +export function rmstSvg(v: DrawnResultOf<"rmst">, o: SvgOpts): string { + const r = v.rmst, p = palette(o.theme), W = Math.max(420, o.width || 760); + const out: string[] = []; + const panels = r.panels; + const cols = Math.max(1, Math.min(panels.length, Math.floor((W - 16) / 440))); + const GAP = 20, pw = (W - (cols - 1) * GAP) / cols; + const xFrom = (L: number) => "Months from " + (L > 0 ? "the landmark" : r.anchoredTo || "the study's day 0"); + let y = 0; + for (let row = 0; row * cols < panels.length; row++) { + let rowH = 0; + panels.slice(row * cols, row * cols + cols).forEach((pn, j) => { + const km = kmPanel(pn.curves.map((c) => ({ name: c.cohort, points: c.points, atRisk: c.atRisk, + detail: "RMST " + rmstText(c) + " · n = " + fmtInt(c.n) })), + { y0: 0, W: pw, p, xTitle: xFrom(pn.landmark), yTitle: "Probability alive", title: panelTitle(pn.landmark, pn.tau), shadeTo: pn.tau, + tMax: Math.max(pn.tau * 1.5, Math.min(pn.tau * 3, Math.max(1, ...pn.curves.flatMap((c) => c.points.map((q) => q.t))))) }); + out.push(`${km.svg}`); + rowH = Math.max(rowH, km.height); + }); + y += rowH + 8; + } + + // the table: one block per landmark + const head = ["cohort", "n", "RMST (95% CI)", "difference (95% CI)", "ratio (95% CI)"]; + const blocks = panels.map((pn) => ({ pn, rows: pn.curves.map((c, i) => { + const cmp = pn.comparisons.find((x) => x.cohort === c.cohort); + return { i, cells: [c.cohort, fmtInt(c.n), rmstText(c), i === 0 ? "reference" : cmp ? rmstDiffText(cmp) : "–", i === 0 ? "" : cmp ? rmstRatioText(cmp) : "–"] }; + }) })); + const all = blocks.flatMap((b) => b.rows); + const nameW = Math.min(W * 0.3, Math.max(textWidth(head[0], FS.small), ...all.map((x) => textWidth(x.cells[0], FS.body) + 22))); + const colW = [1, 2, 3, 4].map((k) => Math.max(textWidth(head[k], FS.small), ...all.map((x) => textWidth(x.cells[k], FS.body)))); + const CG = 14; + const fits = 8 + nameW + colW.reduce((a, w) => a + w + CG, 0) <= W - 8; + // narrow: n and RMST on the first line, difference and ratio under them + const colX: number[] = []; + let cx = 8 + nameW + CG; + colW.forEach((w, k) => { colX.push(cx); cx += w + CG; if (!fits && k === 1) cx = 8 + nameW + CG; }); + const cellW = (k: number) => !fits && k === 3 ? W - colX[k] - 8 : colW[k]; + const ROW = fits ? 18 : 30; + for (const b of blocks) { + out.push(label(8, y + FS.body, panelTitle(b.pn.landmark, b.pn.tau) + (b.pn.tauSet ? "" : " (every cohort ≥ 10 at risk)"), { size: FS.body, fill: p.ink, weight: 600, maxW: W - 16 })); + y += FS.body + 6; + head.forEach((h, k) => { + const line2 = !fits && k >= 3; + out.push(label(k === 0 ? 8 : colX[k - 1], y + FS.small + (line2 ? FS.small + 4 : 0), h, { size: FS.small, fill: p.muted, maxW: k === 0 ? nameW : cellW(k - 1) })); + }); + y += FS.small + 6 + (fits ? 0 : FS.small + 4); + for (const x of b.rows) { + const base = y + FS.body, st = seriesStyle(x.i); + out.push(lineSwatch(8, base - FS.body * 0.35, st.color, st.dash)); + out.push(label(30, base, x.cells[0], { size: FS.body, fill: p.ink, maxW: nameW - 22 })); + x.cells.slice(1).forEach((t, k) => { + if (!t) return; + const line2 = !fits && k >= 2; + out.push(label(colX[k], base + (line2 ? 13 : 0), t, { size: line2 ? FS.small : FS.body, fill: k === 0 ? p.muted : p.ink, maxW: cellW(k) })); + }); + y += ROW; + } + y += 8; + } + const cap = caption(v.caveat || "", W, y, p.muted); + return frame(W, y + cap.height + 8, out.join("") + cap.svg, p.bg, false, "Restricted mean survival time — " + r.endpointLabel); +} diff --git a/lib/figures/txendpoints.ts b/lib/figures/txendpoints.ts new file mode 100644 index 0000000..0799727 --- /dev/null +++ b/lib/figures/txendpoints.ts @@ -0,0 +1,30 @@ +// Treatment endpoints figure: Kaplan–Meier curves of the endpoint per cohort from the treatment +// anchor, the number at risk, each cohort's median with its interval, and the log-rank test. +import type { DrawnResultOf, TxendpointsCurve } from "../types.ts"; +import { palette, frame, caption, label, fmtNum, fmtP, fmtInt, FS, type SvgOpts } from "../svg-kit.ts"; +import { kmPanel } from "./km.ts"; + +const fmtMo = (m: number | null) => m == null ? "not reached" : fmtNum(Math.round(m * 10) / 10) + " mo"; + +/** a curve's median and interval in words, for the figure and the text summary */ +export const txMedianText = (c: Pick) => + "median " + fmtMo(c.median) + (c.median != null ? " (95% CI " + fmtMo(c.medianLo) + "–" + (c.medianHi == null ? "NR" : fmtMo(c.medianHi)) + ")" : ""); + +export function txendpointsSvg(v: DrawnResultOf<"txendpoints">, o: SvgOpts): string { + const t = v.txendpoints, p = palette(o.theme), W = Math.max(360, o.width || 720); + const curves = t.curves.filter((c) => c.n > 0); + const out: string[] = []; + const km = kmPanel(curves.map((c) => ({ name: c.cohort, points: c.points, atRisk: c.atRisk, + detail: txMedianText(c) + " · n = " + fmtInt(c.n) + " · " + fmtInt(c.events) + " events" })), + { y0: 0, W, p, xTitle: "Months from " + t.anchoredTo, yTitle: "Probability free of the event", ...(t.cutoffMonths != null ? { tMax: t.cutoffMonths } : {}) }); + out.push(km.svg); + let y = km.height + 4; + const line = t.logrank + ? (t.logrank.groups + "-group log-rank χ² = " + fmtNum(t.logrank.chi2) + " (" + t.logrank.df + " df) · " + fmtP(t.logrank.p) + (t.logrank.strata > 1 ? " · stratified by study, " + t.logrank.strata + " strata" : "")) + : (t.noTest || ""); + if (line) { out.push(label(8, y + FS.body, line, { size: FS.body, fill: p.ink, maxW: W - 16, extra: 'class="logrank"' })); y += Math.round(FS.body * 1.6); } + const left = t.curves.filter((c) => !c.n).map((c) => c.cohort); + const cap = caption((left.length ? "Not drawn (no patient entered the risk set): " + left.join(", ") + ". " : "") + (v.caveat || ""), W, y, p.muted); + out.push(cap.svg); + return frame(W, y + cap.height + 8, out.join(""), p.bg, false, "Treatment endpoints — " + t.endpointLabel + " from " + t.anchoredTo); +} diff --git a/lib/figures/volcano.ts b/lib/figures/volcano.ts index 82a9b84..e6e1da5 100644 --- a/lib/figures/volcano.ts +++ b/lib/figures/volcano.ts @@ -32,10 +32,12 @@ const EDGE = 6; export function volcano(p: Pal, o: { x0: number; y0: number; width: number; plotH: number; grid: VolcanoGrid; pts: VolcanoPoint[]; labels: string[]; xTitle: string; sides?: [string, string]; r?: number; cellColor?: (cls: number) => string; noun?: string; + /** what `p` is, for the y axis's title (default "p") */ + pName?: string; }): { svg: string; height: number; unplaced: string[] } { const g = o.grid, r = o.r ?? 2.6; const T = o.y0 + FS.small; - const yTitle = "−log₁₀ p"; + const yTitle = "−log₁₀ " + (o.pName ?? "p"); const ys = linear(0, g.yMax, T + o.plotH - PAD, T + PAD, { nice: false }); const L = o.x0 + EDGE + leftAxisWidth(ys, yTitle), R = o.x0 + o.width - 6; const xs = linear(-g.xMax, g.xMax, L + PAD, R - PAD, { nice: false }); diff --git a/lib/new-in-lab.ts b/lib/new-in-lab.ts new file mode 100644 index 0000000..245c11d --- /dev/null +++ b/lib/new-in-lab.ts @@ -0,0 +1,14 @@ +// View kinds cBioPortal has no counterpart for. The Examples tab and the add-view buttons mark +// them NEW. A kind the portal draws in some form (its Comparison, Plots, Mutations, Pathways or +// patient-view tabs) is left out, even where this one goes further. Browser-safe. +const NEW_KINDS = new Set([ + "cox", "logistic", "riskmodel", "txendpoints", "multistate", "rmst", "predictive", "cutpoint", "forest", "hla", + "course", "sequence", "incidence", "labs", "attrition", + "adjusted", "actionability", "drivernegative", "exclusivity", "organotropism", "molsubtype", "germline", + "paired", "acquired", "ctdna", "clonality", "genome", "instability", "composition", +]); + +export const isNewKind = (kind: unknown): boolean => NEW_KINDS.has(String(kind)); + +/** the mark's hover text */ +export const NEW_TITLE = "not in cBioPortal"; diff --git a/lib/preview-kinds.ts b/lib/preview-kinds.ts new file mode 100644 index 0000000..0c48fb6 --- /dev/null +++ b/lib/preview-kinds.ts @@ -0,0 +1,27 @@ +// View kinds shown as previews: drawn and usable, but not yet held to invariant 12 (no method +// record, no golden test of their statistic). Each entry says what is pending. The view header +// marks these with a Preview badge, and scripts/check-method-scripts.ts exempts them by this list, +// so a kind leaves both the moment its entry is removed. Browser-safe. +export const PREVIEW_KINDS: Record = { + acquired: "no method record or golden test of the before/after pairing and McNemar counts yet", + ctdna: "no method record or golden test of the agreement counts, Wilson intervals and survival split, or of the clonal-haematopoiesis filter (chFilter) yet", + exclusivity: "no method record or golden test of the DISCOVER background fit against the R package yet", + hla: "no method record or golden test of the homozygosity and LOH counts or the survival splits yet", + cutpoint: "no method record or golden test of the maximally selected log-rank scan against R maxstat yet", + predictive: "no method record or golden test of the per-gene interaction fits yet", + rmst: "no method record or golden test of the restricted means, their variances and the landmark sets against R survRM2 yet", + multistate: "no method record or golden test of the Aalen–Johansen occupation, Nelson–Aalen hazards or the delayed-entry Cox fits yet (no interval on the occupation)", + txendpoints: "no method record or golden test of the endpoint derivation (lines, stops, progression) or the median intervals yet", + forest: "no method record or golden test of the per-stratum Cox fits and the DerSimonian–Laird pooling yet", + adjusted: "no method record or golden test of the per-gene logistic fits yet", + instability: "no method record, and no check of the arm calls and aneuploidy scores against Taylor et al.'s published values yet", + actionability: "no method record of the per-patient highest-level counts yet", + drivernegative: "no method record or golden test of the group split and the per-gene Fisher tests yet", + germline: "no method record or golden test of the carrier and second-hit counts or the Fisher tests yet", + molsubtype: "no method record or golden test of the per-patient classification, the chi-squared tests or the survival split yet", + organotropism: "no method record or golden test of the per-cell Cochran–Mantel–Haenszel tests or the site incidence yet", +}; + +/** what a preview kind still lacks, or null for a kind held to the full checks */ +export const previewPending = (kind: unknown): string | null => + Object.prototype.hasOwnProperty.call(PREVIEW_KINDS, String(kind)) ? PREVIEW_KINDS[String(kind)] : null; diff --git a/lib/result-text.ts b/lib/result-text.ts index 4c372d6..a530d1b 100644 --- a/lib/result-text.ts +++ b/lib/result-text.ts @@ -5,6 +5,9 @@ import { isDrawn, type AnalysisDoc, type AnalysisResult, type ViewResult, type AssaysEntity, type CorrelatesGene } from "./types.ts"; import { lollipopSummaryText } from "./lollipop-svg.ts"; import { riskSpan } from "./figures/riskmodel.ts"; +import { txMedianText } from "./figures/txendpoints.ts"; +import { MS_TRANSITIONS, msRateText } from "./figures/multistate.ts"; +import { rmstText, rmstDiffText, rmstRatioText } from "./figures/rmst.ts"; /** a timeline's events listed in text (MCP); the figure draws every one */ const TIMELINE_TEXT_EVENTS = 400; @@ -99,6 +102,71 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] ` ${c.label}${c.reference ? " vs " + c.reference : ""}: HR ${c.hr.toFixed(2)} ` + `(95% CI ${c.ciLow.toFixed(2)}-${c.ciHigh.toFixed(2)}) p=${pFmt(c.p)}`).join("\n") + caveatOf(v)); + } else if (v.kind === "forest") { + const f = v.forest, hr = (x: { hr: number; ciLow: number; ciHigh: number; p: number }) => + `HR ${x.hr.toFixed(2)} (95% CI ${x.ciLow.toFixed(2)}-${x.ciHigh.toFixed(2)}) p=${pFmt(x.p)}`; + out.push(`Forest plot: ${f.endpoint} hazard ratio for ${f.exposedLabel} by ${f.strata}` + (f.covariates.length ? `, adjusted for ${f.covariates.join(", ")}` : "") + + ` (n=${f.n.toLocaleString()}, ${f.events.toLocaleString()} events):\n` + + f.rows.map((r) => ` ${r.label} (n=${r.n.toLocaleString()}, ${r.events} events, ${r.exposed} exposed): ${hr(r)}${f.random ? `, weight ${(100 * r.weight).toFixed(1)}%` : ""}`).join("\n") + + (f.random && f.fixed ? `\n pooled, random effects (DerSimonian–Laird): ${hr(f.random)}\n pooled, fixed effect: ${hr(f.fixed)}` + + `\n heterogeneity: tau²=${f.tau2.toFixed(3)}, I²=${(100 * f.i2).toFixed(0)}%, Q=${f.q.toFixed(1)} on ${f.qDf} df, p=${pFmt(f.qP)}` : "") + + (f.left.length ? `\n not estimated: ` + f.left.map((l) => `${l.label} (${l.reason})`).join("; ") : "") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "adjusted") { + const a = v.adjusted, pct = (x: number, n: number) => n ? (100 * x / n).toFixed(1) + "%" : "–"; + out.push(`Adjusted enrichment: ${a.groups[0]} vs ${a.groups[1]}, mutation${a.measure === "driver" ? " (OncoKB drivers)" : ""} per gene by logistic regression` + + (a.covariates.length ? ` adjusted for ${a.covariates.join(", ")}` : " (no covariates)") + `; ${a.rows.length} genes fit, ${a.significant} at q<0.05:\n` + + a.rows.slice(0, 30).map((g) => ` ${g.gene}: ${a.groups[0]} ${g.altered[0]}/${g.profiled[0]} (${pct(g.altered[0], g.profiled[0])}), ${a.groups[1]} ${g.altered[1]}/${g.profiled[1]} (${pct(g.altered[1], g.profiled[1])}); ` + + `raw OR ${g.rawOr == null ? "undefined" : g.rawOr.toFixed(2)} (Fisher p=${pFmt(g.fisherP)}); adjusted OR ${g.or.toFixed(2)} (95% CI ${g.ciLow.toFixed(2)}-${g.ciHigh.toFixed(2)}) p=${pFmt(g.p)} q=${pFmt(g.q)}`).join("\n") + + (a.rows.length > 30 ? `\n … ${a.rows.length - 30} more genes fit` : "") + + (a.failed.length ? `\n not fit: ` + a.failed.map((f) => `${f.gene} (${f.reason})`).join("; ") : "") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "actionability") { + const a = v.actionability, pct = (k: number, d: number) => d ? (100 * k / d).toFixed(1) + "%" : "–"; + const line = (label: string, g: { sequenced: number; levels: number[]; resistance: number[] }) => + ` ${label} (${g.sequenced.toLocaleString()} sequenced): ` + a.levels.map((l, i) => `${l} ${g.levels[i]} (${pct(g.levels[i], g.sequenced)})`).join(", ") + + `; resistance R1 ${g.resistance[0]}, R2 ${g.resistance[1]}`; + out.push(`Clinical actionability (highest OncoKB level per patient, ${a.pin}, any cancer type${a.groupBy ? `, by ${a.groupBy}` : ""}):\n` + + a.panels.map((pn) => [line(pn.cohort, pn), ...(pn.groups || []).map((g) => line(" " + g.label, g)), + ...(pn.concordance ? [` ${a.pairBy!.a.join("/")} vs ${a.pairBy!.b.join("/")} highest level, ${pn.concordance.pairs} patients with both: ${pn.concordance.discordant} discordant (${pct(pn.concordance.discordant, pn.concordance.pairs)}); rows ${a.pairBy!.a.join("/")}, columns ${a.pairBy!.b.join("/")} (1, 2, 3A, 3B, 4, none): ` + + pn.concordance.table.map((r) => "[" + r.join(", ") + "]").join(" ")] : [])].join("\n")).join("\n") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "germline") { + const gm = v.germline, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + out.push(`Germline carriers and somatic second hits (${gm.chosen || gm.genes.length + " genes"}; LOH ${gm.loh}):\n` + + gm.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients):\n` + pn.genes.map((g) => + ` ${g.gene}: ${g.carriers}/${g.sequenced} sequenced carry a germline call (${pct(g.carriers, g.sequenced)})${g.outsidePanel ? `, ${g.outsidePanel} more off their panel` : ""}; ` + + `second hit in carriers ${g.carrierEither}/${g.carriers} (${pct(g.carrierEither, g.carriers)}: ${g.carrierSomatic} somatic mutation, ${g.carrierDeletion}/${g.carrierCnaProfiled} deep deletion) vs non-carriers ${g.noncarrierEither}/${g.noncarriers} (${pct(g.noncarrierEither, g.noncarriers)})` + + (g.p != null ? `, Fisher p=${pFmt(g.p)} q=${pFmt(g.q!)}` : "")).join("\n")).join("\n") + + (gm.survival || []).map((s) => `\n OS by ${s.gene} carrier status, ${s.cohort}: ` + s.curves.map((c) => `${c.group} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + " mo"}`).join("; ") + + (s.logrank ? `; log-rank p=${pFmt(s.logrank.p)}` : s.noTest ? "; " + s.noTest : "")).join("") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "molsubtype") { + const m = v.molsubtype, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + const line = (label: string, c: { counts: number[]; classified: number; notClassifiable: number }) => + ` ${label} (${c.classified.toLocaleString()} classified, ${c.notClassifiable.toLocaleString()} not classifiable): ` + m.subtypes.map((s, i) => `${s} ${c.counts[i]} (${pct(c.counts[i], c.classified)})`).join(", "); + out.push(`Molecular subtypes — ${m.classifierLabel}; MSI by ${m.msi}; p53 abnormal = ${m.p53}:\n` + + m.panels.map((pn) => [line(pn.cohort, pn), ...(pn.groups || []).map((g) => line(" " + g.label, g)), + ...(pn.byTest ? [` by ${m.by}: chi-squared ${pn.byTest.chi2.toFixed(1)} (${pn.byTest.df} df), p=${pFmt(pn.byTest.p)}`] : [])].join("\n")).join("\n") + + (m.cohortTest ? `\n Between cohorts: chi-squared ${m.cohortTest.chi2.toFixed(1)} (${m.cohortTest.df} df), p=${pFmt(m.cohortTest.p)}` : "") + + (m.survival || []).map((s) => `\n OS by subtype, ${s.cohort}: ` + s.curves.map((c) => `${c.subtype} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + " mo"}`).join("; ") + + (s.logrank ? `; log-rank chi-squared ${s.logrank.chi2.toFixed(1)} (${s.logrank.df} df), p=${pFmt(s.logrank.p)}` : s.noTest ? "; " + s.noTest : "")).join("") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "organotropism") { + const o = v.organotropism, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + const lab = new Map(o.sites.map((s) => [s.site, s.label])); + const sig = o.cells.filter((c) => c.q != null && c.q < 0.05).sort((a, b) => a.p! - b.p!); + out.push(`Metastatic organotropism (${o.patients.toLocaleString()} patients; sites from ${o.sourceLabel}; ${o.measure === "driver" ? "OncoKB-driver " : ""}${o.alteration}s; ` + + `${o.stratifyBy ? "CMH stratified by " + o.stratifyBy : "unstratified"}): ${o.genes.length} genes × ${o.sites.length} sites, ${o.tested} cells tested, ${o.significant} at q<0.05.\n` + + ` Sites (patients with a metastasis there / evaluable): ` + o.sites.map((s) => `${s.label} ${s.withSite}/${s.evaluable}`).join(", ") + "\n" + + sig.slice(0, 30).map((c) => ` ${lab.get(c.site) || c.site} × ${c.gene}: altered ${c.withAltered}/${c.withSite} (${pct(c.withAltered, c.withSite)}) with the site vs ${c.withoutAltered}/${c.without} (${pct(c.withoutAltered, c.without)}) without, ` + + `MH OR ${c.or == null ? "undefined" : c.or.toFixed(2)}, p=${pFmt(c.p!)} q=${pFmt(c.q!)}`).join("\n") + + (o.incidence ? `\n Cumulative incidence of ${o.incidence.site} by ${o.incidence.gene} (death competing): ` + + o.incidence.horizons.map((h) => `${h.months} mo ` + h.cells.map((c) => `${c.cohort} ${(100 * c.f1).toFixed(1)}% [${(100 * c.lo).toFixed(1)}–${(100 * c.hi).toFixed(1)}]`).join(" vs ")).join("; ") + + (o.incidence.gray ? `; Gray's test p=${pFmt(o.incidence.gray.p)}` : "") : o.noIncidence ? `\n No incidence panel: ${o.noIncidence}` : "") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "drivernegative") { + const d = v.drivernegative, pct = (k: number, n: number) => n ? (100 * k / n).toFixed(1) + "%" : "–"; + out.push(`Driver-negative analysis (drivers: ${d.drivers.join(", ")}): of ${d.sequenced.toLocaleString()} patients sequenced, ` + + `${d.positive.toLocaleString()} driver-positive (${pct(d.positive, d.sequenced)}), ${d.negative.toLocaleString()} driver-negative (${pct(d.negative, d.sequenced)}), ` + + `${d.unprofiled.toLocaleString()} not fully profiled (in neither group).\n` + + ` ${d.alteration} frequency, driver-negative vs driver-positive, Fisher + BH over ${d.tested} genes, ${d.significant} at q<0.05:\n` + + d.rows.slice(0, 30).map((g) => ` ${g.gene}: ${g.altered[0]}/${g.profiled[0]} (${pct(g.altered[0], g.profiled[0])}) vs ${g.altered[1]}/${g.profiled[1]} (${pct(g.altered[1], g.profiled[1])}), ` + + `log2 ratio ${g.log2Ratio == null ? "undefined" : g.log2Ratio.toFixed(2)}, p=${pFmt(g.p)} q=${pFmt(g.q)}`).join("\n") + caveatOf(v) + noteOf(v)); } else if (v.kind === "logistic") { out.push(`Logistic regression (n=${v.logistic.n.toLocaleString()}, ${v.logistic.events.toLocaleString()} with the outcome):\n` + v.logistic.coefficients.map((c) => @@ -257,6 +325,47 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] (g.concordance != null ? `, ${Math.round(100 * g.concordance)}% concordant` : "") + (g.aOnly + g.bOnly ? `, McNemar q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + caveatOf(v) + noteOf(v)); + } else if (v.kind === "acquired") { + const ar = v.acquired; + out.push(`Acquired under therapy: last sample on or before the first ${ar.anchoredTo} against the first at least ${ar.minGapDays} days after (${ar.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}; both samples' panels cover the gene):\n` + + ar.panels.map((pn) => ` ${pn.cohort} (${pn.anchored.toLocaleString()} started, ${pn.withPre.toLocaleString()} with a sample before, ${pn.withPost.toLocaleString()} after, ${pn.patients.toLocaleString()} with both` + + (pn.gapDays ? `; samples a median ${pn.gapDays.median} days apart` : "") + + (pn.tested != null ? `; ${pn.tested.toLocaleString()} genes tested, the ${pn.genes.length} smallest p listed` : "") + `):\n` + + pn.genes.map((g) => ` ${g.gene}: acquired ${g.acquired}, lost ${g.lost}, shared ${g.shared}, neither ${g.neither} of ${g.pairs} pairs` + + (g.acquired + g.lost ? `, McNemar p=${pFmt(g.p)} q=${pFmt(g.q)}` : "")).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "ctdna") { + const cr = v.ctdna, pc = (x: number | null) => x == null ? "—" : (100 * x).toFixed(0) + "%"; + out.push(`ctDNA vs tissue (plasma ${cr.attribute} = ${cr.plasma.join("/")}${cr.plasmaPanels.length ? " or panel " + cr.plasmaPanels.join("/") : ""}, tissue ${cr.tissue.join("/")}; ` + + `${cr.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}${cr.withinDays != null ? `; samples within ${cr.withinDays} days` : ""}; both panels cover the gene):\n` + + cr.panels.map((pn) => ` ${pn.cohort} (${pn.patients.toLocaleString()} patients with both; ctDNA detected in ${pn.detected.toLocaleString()} of ${pn.plasmaPatients.toLocaleString()} with plasma, ` + + `${pc(pn.plasmaPatients ? pn.detected / pn.plasmaPatients : null)} [95% CI ${pc(pn.detectedLow)}–${pc(pn.detectedHigh)}]):\n` + + pn.genes.map((g) => ` ${g.gene}: both ${g.both}, tissue-only ${g.tissueOnly}, plasma-only ${g.plasmaOnly}, neither ${g.neither} of ${g.pairs}; ` + + `PPA ${pc(g.ppa)}${g.ppa != null ? ` [${pc(g.ppaLow)}–${pc(g.ppaHigh)}]` : ""}, concordance ${pc(g.concordance)}`).join("\n") + + (pn.survival ? `\n OS: ` + pn.survival.curves.map((c) => `${c.label} n=${c.n}, ${c.events} deaths, median ${c.median == null ? "not reached" : c.median.toFixed(1) + "mo"}`).join("; ") + + (pn.survival.logrank ? `; log-rank p=${pFmt(pn.survival.logrank.p)}` : "") : pn.survivalWhy ? `\n OS by ctDNA not drawn: ${pn.survivalWhy}` : "") + + (pn.ch ? `\n CH filter: removed ${pn.ch.removed} of ${pn.ch.calls} plasma mutation calls in ${pn.ch.patients} patients (${pn.ch.byGene} by gene, ${pn.ch.byNormal} by the matched normal)` + + (pn.ch.genes.length ? ": " + pn.ch.genes.map((g) => `${g.gene} ${g.calls}`).join(", ") : "") + + `; unfiltered: ctDNA detected ${pn.ch.before.detected.toLocaleString()} (${pc(pn.plasmaPatients ? pn.ch.before.detected / pn.plasmaPatients : null)})` + + (pn.ch.before.survival && pn.ch.before.survival.p != null ? `, OS log-rank p=${pFmt(pn.ch.before.survival.p)}` : "") + + `; plasma-only unfiltered ` + pn.ch.before.genes.map((g) => `${g.gene} ${g.plasmaOnly}`).join(", ") : "")).join("\n") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "exclusivity") { + const ex = v.exclusivity; + out.push(`Mutual exclusivity (DISCOVER background over ${ex.samples.toLocaleString()} samples and ${ex.matrixGenes} genes${ex.stratifyBy ? `, within ${ex.strata.length} ${ex.stratifyBy} strata` : ""}; ` + + `${ex.measure === "driver" ? "OncoKB oncogenic" : "any alteration"}; fit ${ex.converged ? "converged" : "NOT converged"} in ${ex.iterations} rounds) in ${ex.cohort}:\n` + + ex.pairs.map((x) => ` ${x.a}/${x.b}: both ${x.both} of ${x.profiled} profiled, ${x.expected} expected; DISCOVER exclusivity q=${pFmt(x.qExclusive)}, co-occurrence q=${pFmt(x.qCooccur)}` + + `${x.exact ? "" : " (normal approx.)"}; Fisher q=${pFmt(x.fisherQ)}${x.log2Odds != null ? `, log2 OR ${x.log2Odds.toFixed(2)}` : ""}`).join("\n") + + (ex.unassessable.length ? `\n not assessable: ${ex.unassessable.join(", ")}` : "") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "instability") { + const ir = v.instability, f = (s: { median: number | null; q1: number | null; q3: number | null }) => s.median == null ? "—" : `${s.median} [${s.q1}–${s.q3}]`; + out.push(`Genomic instability (hg19 segments, threshold ±${ir.threshold}, arm called past ${Math.round(ir.armShare * 100)}% of its length; ${ir.arms.length} arms):\n` + + ir.panels.map((pn) => ` ${pn.cohort}: ${pn.score.reduce((s, x) => s + x, 0).toLocaleString()} samples scored; aneuploidy score median [IQR] ${f(pn.scoreSpread)}, FGA ${f(pn.fgaSpread)}%\n` + + ` most often gained: ${[...pn.arms].filter((a) => a.measured).sort((a, b) => b.gain / b.measured - a.gain / a.measured).slice(0, 5).map((a) => `${a.arm} ${Math.round(100 * a.gain / a.measured)}%`).join(", ")}\n` + + ` most often lost: ${[...pn.arms].filter((a) => a.measured).sort((a, b) => b.loss / b.measured - a.loss / a.measured).slice(0, 5).map((a) => `${a.arm} ${Math.round(100 * a.loss / a.measured)}%`).join(", ")}`).join("\n") + + (ir.test ? `\n ${ir.test.name}: aneuploidy score p=${pFmt(ir.test.scoreP)}, FGA p=${pFmt(ir.test.fgaP)}` : "") + + caveatOf(v) + noteOf(v)); } else if (v.kind === "clonality") { const cr = v.clonality; out.push(`Clonality (FACETS; ${cr.measure === "driver" ? "driver mutation calls" : "all mutation calls"}${cr.qc ? ", QC-passing samples" : ""}):\n` + @@ -290,7 +399,67 @@ export function analysisText(document: AnalysisDoc, r: AnalysisResult): string[] (m.refit ? `\n risk score alone: HR ${m.refit.hr.toFixed(2)} per SD (95% CI ${m.refit.ciLow.toFixed(2)}–${m.refit.ciHigh.toFixed(2)}, ${pFmt(m.refit.p)})` : "") + "\n genes kept most often: " + m.features.slice(0, 15).map((f) => `${f.gene} ${Math.round(f.selected * 100)}% HR ${f.hr.toFixed(2)}`).join(", ") + (m.neverHeldOut ? `\n ${m.neverHeldOut} patients were never held out, so have no score` : "") + + (m.external ? `\n external validation on ${m.external.cohort} (${m.external.study}; ${m.external.n} patients scored, ${m.external.notCovered} not covered by the model's genes): ` + + `C-index ${m.external.cIndex.toFixed(3)} (95% bootstrap CI ${m.external.cIndexLo.toFixed(3)}–${m.external.cIndexHi.toFixed(3)})` + + (m.external.logrank ? `, log-rank p=${pFmt(m.external.logrank.p)}` : "") + "\n" + + m.external.groups.map((g) => ` ${g.name}: n=${g.curve.n}, ${g.curve.events} events, median ${g.curve.median == null ? "not reached" : g.curve.median.toFixed(1) + "mo"}` + + (g.hr != null ? `, HR vs lowest ${g.hr.toFixed(2)} (95% CI ${g.ciLow!.toFixed(2)}–${g.ciHigh!.toFixed(2)})` : "")).join("\n") : "") + caveatOf(v) + methodOf(v) + noteOf(v)); + } else if (v.kind === "cutpoint") { + const m = v.cutpoint; + const side = (s: { n: number; events: number; median: number | null }) => `n=${s.n}, ${s.events} events, median ${s.median == null ? "not reached" : s.median.toFixed(1) + "mo"}`; + out.push(`Cut-point of ${m.attribute} on ${m.endpoint} (maximally selected log-rank, ${m.candidates.length} candidate cuts between the 10th and 90th percentiles; ${m.n.toLocaleString()} patients, ${m.events.toLocaleString()} events):\n` + + ` best cut ${m.attribute} > ${m.cut}: high ${side(m.high)}; low ${side(m.low)}` + (m.hr ? `; HR ${m.hr.hr.toFixed(2)} (95% CI ${m.hr.ciLow.toFixed(2)}-${m.hr.ciHigh.toFixed(2)})` : "") + "\n" + + ` corrected p=${pFmt(m.permP)} (${m.permutations} permutations, seed ${m.seed}); naive log-rank p=${pFmt(m.naiveP)}, which ignores the search` + + (m.strata ? "\n per " + m.strata.attribute + ":\n" + m.strata.rows.map((s) => ` ${s.stratum}: n=${s.n}, cut ${s.cut}, high ${s.high.n}, ` + + (s.hr ? `HR ${s.hr.hr.toFixed(2)}, ` : "") + `corrected p=${pFmt(s.permP)}, naive p=${pFmt(s.naiveP)}`).join("\n") : "") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "hla") { + const m = v.hla, pc = (a: number, b: number) => b ? (100 * a / b).toFixed(1) + "%" : "—"; + const hr = (h: { hr: number; ciLow: number; ciHigh: number; p: number; n: number } | null) => h ? `HR ${h.hr.toFixed(2)} (95% CI ${h.ciLow.toFixed(2)}-${h.ciHigh.toFixed(2)}, p=${pFmt(h.p)}, n=${h.n})` : "n/a"; + const split = (name: string, s: (typeof m.panels)[number]["byLoh"], why?: string) => s + ? `\n ${m.endpoint} by ${name}: ` + s.curves.map((c) => `${c.label} n=${c.n}, ${c.events} events, median ${c.median == null ? "not reached" : c.median.toFixed(1) + "mo"}`).join("; ") + + (s.logrank ? `; log-rank p=${pFmt(s.logrank.p)}` : "") + `; ${hr(s.hr)}` + (m.covariates.length ? `; adjusted for ${m.covariates.join(", ")} ${hr(s.adjusted)}` : "") + : `\n ${m.endpoint} by ${name} not drawn: ${why || "no data"}`; + out.push(`HLA class I genotype (alleles compared at ${m.resolution === "full" ? "full" : m.resolution + "-field"} resolution) and HLA LOH:\n` + + m.panels.map((pn) => ` ${pn.cohort} (${pn.typed.toLocaleString()} of ${pn.patients.toLocaleString()} patients typed at A, B and C): homozygous at 0 loci ${pc(pn.homozygous[0], pn.typed)}, ` + + `1 ${pc(pn.homozygous[1], pn.typed)}, 2+ ${pc(pn.homozygous[2] + pn.homozygous[3], pn.typed)}; ` + pn.loci.map((l) => `HLA-${l.locus} ${pc(l.homozygous, l.typed)}`).join(", ") + + `; HLA LOH ${pn.loh.toLocaleString()} of ${pn.lohEvaluated.toLocaleString()} with a call (${pc(pn.loh, pn.lohEvaluated)})` + + split("homozygosity (≥1 locus vs none)", pn.byHomozygosity, pn.byHomozygosityWhy) + split("HLA LOH", pn.byLoh, pn.byLohWhy)).join("\n") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "predictive") { + const m = v.predictive; + const eff = (e: { hr: number; ciLow: number; ciHigh: number; p: number } | null) => e ? `HR ${e.hr.toFixed(2)} (95% CI ${e.ciLow.toFixed(2)}-${e.ciHigh.toFixed(2)}, ${pFmt(e.p)})` : "n/a"; + out.push(`Predictive biomarkers: gene x ${m.exposure} on ${m.endpoint} (Cox per gene; ${m.n.toLocaleString()} patients, ${m.exposed.toLocaleString()} exposed, ${m.events.toLocaleString()} events` + + (m.covariates.length ? `; adjusted for ${m.covariates.join(", ")}` : "") + `):\n` + + m.genes.map((g) => ` ${g.gene}: mutated ${g.mutatedExposed}/${g.exposed} exposed, ${g.mutatedUnexposed}/${g.unexposed} unexposed; ` + + (g.interaction ? `among exposed ${eff(g.inExposed)}; among unexposed ${eff(g.inUnexposed)}; interaction ${eff(g.interaction)}, q=${pFmt(g.q!)}` : `not fitted: ${g.skipped}`)).join("\n") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "rmst") { + const r = v.rmst; + out.push(`Restricted mean survival time, ${r.endpointLabel}:\n` + + r.panels.map((pn) => ` ${pn.landmark ? `from the ${pn.landmark}-month landmark` : "from the start"}, tau=${pn.tau} months${pn.tauSet ? "" : " (default: every cohort >= 10 at risk)"}:\n` + + pn.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, RMST ${rmstText(c)}` + + ((x) => x ? `; vs ${x.vs} difference ${rmstDiffText(x)}, ratio ${rmstRatioText(x)}` : "")(pn.comparisons.find((x) => x.cohort === c.cohort))).join("\n")).join("\n") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "multistate") { + const m = v.multistate; + out.push(`Multistate (illness-death) model, months from ${m.origin}:\n` + + m.cohorts.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()} (${c.prevalent.toLocaleString()} progressed by day 0); ` + + c.rates.map((r) => `${MS_TRANSITIONS[r.key]} ${r.events} events, ${msRateText(r)} per 100 person-months`).join("; ") + + `\n in each state (not progressed / progressed / dead): ` + + c.atHorizon.map((h) => `${h.months} mo ` + (h.p0 == null ? "n/a" : `${(100 * h.p0).toFixed(0)}% / ${(100 * h.p1!).toFixed(0)}% / ${(100 * h.p2!).toFixed(0)}%`)).join(", ")).join("\n") + + m.cox.map((x) => `\n ${MS_TRANSITIONS[x.key]}: ` + (x.noFit ? `no hazard ratio (${x.noFit})` : + x.rows.map((r) => `${r.cohort} vs ${m.reference} HR ${r.hr.toFixed(2)} (95% CI ${r.lo.toFixed(2)}-${r.hi.toFixed(2)}, ${pFmt(r.p)})`).join("; "))).join("") + + caveatOf(v) + noteOf(v)); + } else if (v.kind === "txendpoints") { + const t = v.txendpoints; + out.push(`Treatment endpoint: ${t.endpointLabel} from ${t.anchoredTo} (Kaplan-Meier):\n` + + t.curves.map((c) => ` ${c.cohort}: n=${c.n.toLocaleString()}, ${c.events} events, ${txMedianText(c)}; ` + + `${c.anchored} of ${c.cohortPatients} anchored; left out ${c.excluded.noAnchor} without the anchor, ${c.excluded.noFollowup} without follow-up` + + (t.endpoint === "tot" ? `, ${c.excluded.noStop} without a stop date` : "")).join("\n") + + (t.logrank ? `\n log-rank chi2=${t.logrank.chi2.toFixed(2)} (${t.logrank.df} df), p=${pFmt(t.logrank.p)}` : t.noTest ? `\n ${t.noTest}` : "") + + caveatOf(v) + noteOf(v)); } else if (v.kind === "labs") { const lb = v.labs; out.push(`Lab course: ${lb.test} % change from baseline, weeks from ${lb.anchoredTo}:\n` + diff --git a/lib/stats.ts b/lib/stats.ts index 754e838..8a6fbf1 100644 --- a/lib/stats.ts +++ b/lib/stats.ts @@ -95,6 +95,37 @@ export function kaplanMeier(data: SurvRecord[]): Omit { return { points, median, n: n0, events, atRisk, ...(unobserved ? { unobserved } : {}) }; } +/** + * The Kaplan–Meier median with its 95% interval, read off the pointwise band of S(t) — Greenwood + * variance on the log scale (R survfit's default conf.type "log"): the lower end is the first time + * the band's lower edge reaches 0.5, the upper end the first time its upper edge does. Null where a + * curve never gets there. Same risk sets as kaplanMeier, delayed entry included. + */ +export function kmMedianCi(data: SurvRecord[]): { median: number | null; lo: number | null; hi: number | null } { + const clean = data.filter(d => Number.isFinite(d.t) && d.t >= 0).filter(observable).sort((x, y) => x.t - y.t); + const atRiskAt = riskSweep(clean); + let s = 1, v = 0, i = 0; + let median: number | null = null, lo: number | null = null, hi: number | null = null; + while (i < clean.length) { + const t = clean[i].t; + let d = 0; + while (i < clean.length && clean[i].t === t) { if (clean[i].event) d++; i++; } + const n = atRiskAt(t); + if (d > 0 && n > 0) { + s *= 1 - d / n; + v += n > d ? d / (n * (n - d)) : Infinity; + if (median === null && s <= 0.5) median = t; + // where S reaches 0 the log-scale band is undefined (R reports NA), so it sets no end + const half = Z_975 * Math.sqrt(v); + if (s > 0 && Number.isFinite(half)) { + if (lo === null && s * Math.exp(-half) <= 0.5) lo = t; + if (hi === null && s * Math.exp(half) <= 0.5) hi = t; + } + } + } + return { median, lo, hi }; +} + /** * Administrative censoring at a fixed month: anyone still under observation past `cutoff` * is treated as censored AT the cutoff rather than dropped. Dropping would bias the risk set @@ -140,7 +171,8 @@ export function survivalAxis(curves: { points: { t: number }[] }[], opts?: { cut // when several subjects fail at once. // --------------------------------------------------------------------------- -export interface CoxRow { t: number; event: number; x: number[]; stratum?: string } +/** `entry`: delayed entry — the row is at risk only at times after it (counting-process (entry, t]); the caller passes rows with t > entry */ +export interface CoxRow { t: number; event: number; x: number[]; stratum?: string; entry?: number } export interface CoxFit { beta: number[]; se: number[]; hr: number[]; ciLow: number[]; ciHigh: number[]; p: number[]; loglik: number; n: number; events: number; iterations: number; @@ -205,6 +237,9 @@ function coxScore(rows: CoxRow[], beta: number[]): { ll: number; grad: number[]; // risk set for time t is everyone with t' >= t, which is exactly what's been folded in // by the time the sweep reaches t, since the sweep goes from large t to small t. let cumExp = 0; const cumX = zeros(p), cumXX = zerosMat(p); + // with delayed entry, a row leaves the risk set once the sweep reaches its entry (latest entries first) + const late = srows.filter((r) => r.entry != null && r.entry > -Infinity).sort((a, b) => b.entry! - a.entry!); + let li = 0; let i = sorted.length - 1; while (i >= 0) { const t = sorted[i].t; @@ -224,6 +259,12 @@ function coxScore(rows: CoxRow[], beta: number[]): { ll: number; grad: number[]; for (let a = 0; a < p; a++) for (let b = 0; b < p; b++) tieXX[a][b] += eb * row.x[a] * row.x[b]; } } + while (li < late.length && late[li].entry! >= t) { + const row = late[li++], eb = Math.exp(dot(beta, row.x)); + cumExp -= eb; + for (let a = 0; a < p; a++) cumX[a] -= eb * row.x[a]; + for (let a = 0; a < p; a++) for (let b = 0; b < p; b++) cumXX[a][b] -= eb * row.x[a] * row.x[b]; + } if (dCount > 0) { ll += dot(beta, eventX); for (let a = 0; a < p; a++) grad[a] += eventX[a]; @@ -904,6 +945,44 @@ export function fisherZMeta(parts: { r: number | null; n: number }[]): Correlati }; } +export interface MetaEstimate { beta: number; se: number; ciLow: number; ciHigh: number; p: number } +export interface RandomEffectsMeta { + fixed: MetaEstimate; random: MetaEstimate; + /** between-study variance (DerSimonian–Laird moment estimator, truncated at 0) */ + tau2: number; + /** Cochran's Q, its degrees of freedom and upper-tail p; I² as a share (0–1) */ + q: number; df: number; qP: number; i2: number; + /** each part's share of the random-effects weight */ + weights: number[]; +} + +/** + * Inverse-variance meta-analysis of per-stratum estimates on the log scale (log HR, log OR): + * the fixed-effect estimate, and the DerSimonian–Laird random-effects one, whose weights + * 1/(se² + τ²) let the strata's true effects differ. Wald 95% intervals on the log scale; the + * caller exponentiates. Needs at least two parts with a positive, finite se. + */ +export function dersimonianLaird(parts: { beta: number; se: number }[]): RandomEffectsMeta { + const ok = parts.filter((x) => Number.isFinite(x.beta) && Number.isFinite(x.se) && x.se > 0); + if (ok.length < 2 || ok.length !== parts.length) throw new Error("a random-effects meta-analysis needs at least two estimates, each with a positive, finite standard error"); + const w = ok.map((x) => 1 / (x.se * x.se)); + const sw = w.reduce((a, b) => a + b, 0), sw2 = w.reduce((a, b) => a + b * b, 0); + const bFixed = ok.reduce((a, x, i) => a + w[i] * x.beta, 0) / sw; + const q = ok.reduce((a, x, i) => a + w[i] * (x.beta - bFixed) ** 2, 0); + const df = ok.length - 1; + const tau2 = Math.max(0, (q - df) / (sw - sw2 / sw)); + const ws = ok.map((x) => 1 / (x.se * x.se + tau2)); + const sws = ws.reduce((a, b) => a + b, 0); + const bRandom = ok.reduce((a, x, i) => a + ws[i] * x.beta, 0) / sws; + const est = (beta: number, se: number): MetaEstimate => + ({ beta, se, ciLow: beta - Z_975 * se, ciHigh: beta + Z_975 * se, p: normalP(beta / se) }); + return { + fixed: est(bFixed, Math.sqrt(1 / sw)), random: est(bRandom, Math.sqrt(1 / sws)), + tau2, q, df, qP: chiSquareP(q, df), i2: q > df ? (q - df) / q : 0, + weights: ws.map((x) => x / sws) + }; +} + // --------------------------------------------------------------------------- // Median/IQR and a rank-based test, from a value HISTOGRAM alone — (value, count) // pairs are the complete, lossless distribution for a single-study cohort (nothing was @@ -1160,6 +1239,54 @@ export function mcnemarExact(b: number, c: number): number { return Math.min(1, 2 * tail); } +/** Wilson score interval for k successes in n trials at 95%; null for n = 0. */ +export function wilsonInterval(k: number, n: number): { low: number; high: number } | null { + if (!(n > 0)) return null; + const z2 = Z_975 * Z_975, ph = k / n, den = 1 + z2 / n; + const mid = (ph + z2 / (2 * n)) / den, half = (Z_975 / den) * Math.sqrt(ph * (1 - ph) / n + z2 / (4 * n * n)); + return { low: Math.max(0, mid - half), high: Math.min(1, mid + half) }; +} + +/** + * Both tails of a Poisson-binomial count X = Σ Bernoulli(p) at x: P(X ≤ x) and P(X ≥ x), the + * trials given as groups of `n` alike. Exact (dynamic programming over the counts 0…x+1, the + * last state holding "more than x", so each tail is a sum of positive terms and keeps its + * precision) while trials × (x + 2) ≤ `maxWork`; past that, the refined normal approximation + * (Volkova 1996: the normal with a skewness correction and continuity correction), flagged. + */ +export function poissonBinomialTails(groups: { n: number; p: number }[], x: number, maxWork = 5e6): + { lower: number; upper: number; mean: number; exact: boolean } { + const gs = groups.filter((g) => g.n > 0 && g.p > 0); + const mean = gs.reduce((s, g) => s + g.n * g.p, 0); + const trials = gs.reduce((s, g) => s + g.n, 0); + if (x < 0) return { lower: 0, upper: 1, mean, exact: true }; + if (trials * (x + 2) <= maxWork) { + const K = x + 1; + let d = new Float64Array(K + 1), e = new Float64Array(K + 1); + d[0] = 1; + for (const g of gs) { + const p = Math.min(1, g.p), q = 1 - p; + for (let t = 0; t < g.n; t++) { + e[0] = d[0] * q; + for (let k = 1; k < K; k++) e[k] = d[k] * q + d[k - 1] * p; + e[K] = d[K] + d[K - 1] * p; + [d, e] = [e, d]; + } + } + let lower = 0; + for (let k = 0; k <= x; k++) lower += d[k]; + return { lower: Math.min(1, lower), upper: Math.min(1, d[x] + d[K]), mean, exact: true }; + } + const v = gs.reduce((s, g) => s + g.n * g.p * (1 - g.p), 0); + if (!(v > 0)) return { lower: x >= mean ? 1 : 0, upper: x <= mean ? 1 : 0, mean, exact: false }; + const sd = Math.sqrt(v), gamma = gs.reduce((s, g) => s + g.n * g.p * (1 - g.p) * (1 - 2 * g.p), 0) / (sd * sd * sd); + const phi = (z: number) => Math.exp(-z * z / 2) / Math.sqrt(2 * Math.PI); + const corr = (z: number) => gamma * (1 - z * z) * phi(z) / 6; + const zl = (x + 0.5 - mean) / sd, zu = (x - 0.5 - mean) / sd; + const clamp = (y: number) => Math.min(1, Math.max(0, y)); + return { lower: clamp(erfc(-zl / Math.SQRT2) / 2 + corr(zl)), upper: clamp(erfc(zu / Math.SQRT2) / 2 - corr(zu)), mean, exact: false }; +} + // --------------------------------------------------------------------------- // Competing risks: cumulative incidence (Aalen–Johansen) and Gray's test @@ -1289,3 +1416,129 @@ export function grayPermutationTest(groups: CrRecord[][], permutations = 2000, s } return { statistic: observed, p: (beyond + 1) / (permutations + 1), permutations, groups: K }; } + +// --------------------------------------------------------------------------- +// Illness–death model: start → progression → death, and start → death, on one clock (Markov, +// clock-forward: every transition's hazard is a function of time since the origin). + +/** One patient's path, months from the origin. */ +export interface IllnessDeathRecord { + /** already in the middle state (progressed) at time 0 */ + prevalent: boolean; + /** the move into the middle state, strictly before `te`; null when none was observed (or prevalent) */ + tp: number | null; + /** the end of follow-up: death when `dead`, else censoring */ + te: number; dead: boolean; +} +export type IllnessDeathTransition = "01" | "02" | "12"; +export interface IllnessDeathFit { + n: number; prevalent: number; + /** events per transition, and censorings in the start (c0) and middle (c1) states */ + events: Record & { c0: number; c1: number }; + /** person-time spent in the start state and in the middle state */ + personTime: { s0: number; s1: number }; + /** Aalen–Johansen state occupation after each event time (p0 start, p1 middle, p2 dead); first point t = 0 */ + occupation: { t: number; p0: number; p1: number; p2: number }[]; + /** Nelson–Aalen cumulative hazard per transition at its event times, with the Aalen variance; first point t = 0 */ + cumhaz: Record; +} + +const lowerBound = (a: number[], x: number) => { let lo = 0, hi = a.length; while (lo < hi) { const m = (lo + hi) >> 1; if (a[m] < x) lo = m + 1; else hi = m; } return lo; }; + +/** + * Nelson–Aalen and Aalen–Johansen for the illness–death model. At each event time t the start state's + * risk set is everyone not prevalent still in it (exit time ≥ t), the middle state's those who entered + * it before t and have not left (entry < t ≤ exit); a move into the middle state at t leaves the start + * state at t and is at risk of death only after it. The cumulative hazard of a transition rises by + * d/n; the occupation probabilities follow the product of (I + dA) from the initial distribution + * (the prevalent share starts in the middle state): p0 ← p0 (1 − (d01 + d02)/n0), p1 ← p1 (1 − d12/n1) + * + p0 d01/n0, p2 = 1 − p0 − p1. + */ +export function illnessDeath(recs: IllnessDeathRecord[]): IllnessDeathFit { + const n = recs.length; + const exit0: number[] = [], in1: number[] = [], out1: number[] = []; + const ev = new Map(); + const bump = (t: number, k: 0 | 1 | 2) => { const e = ev.get(t) || [0, 0, 0]; e[k]++; ev.set(t, e); }; + const events = { "01": 0, "02": 0, "12": 0, c0: 0, c1: 0 }; + let s0 = 0, s1 = 0, prevalent = 0; + for (const r of recs) { + if (r.prevalent) prevalent++; + else { + const x = r.tp ?? r.te; + exit0.push(x); s0 += x; + if (r.tp != null) { events["01"]++; bump(x, 0); } + else if (r.dead) { events["02"]++; bump(x, 1); } + else events.c0++; + } + if (r.prevalent || r.tp != null) { + const a = r.prevalent ? 0 : r.tp!; + in1.push(a); out1.push(r.te); s1 += r.te - a; + if (r.dead) { events["12"]++; bump(r.te, 2); } else events.c1++; + } + } + exit0.sort((a, b) => a - b); in1.sort((a, b) => a - b); out1.sort((a, b) => a - b); + const times = [...ev.keys()].sort((a, b) => a - b); + let p0 = n ? (n - prevalent) / n : 0, p1 = n ? prevalent / n : 0; + const occupation = [{ t: 0, p0, p1, p2: 0 }]; + const H = [0, 0, 0], V = [0, 0, 0]; + const cumhaz: IllnessDeathFit["cumhaz"] = { "01": [{ t: 0, h: 0, v: 0 }], "02": [{ t: 0, h: 0, v: 0 }], "12": [{ t: 0, h: 0, v: 0 }] }; + const keys: IllnessDeathTransition[] = ["01", "02", "12"]; + for (const t of times) { + const [d01, d02, d12] = ev.get(t)!; + const n0 = exit0.length - lowerBound(exit0, t); + const n1 = lowerBound(in1, t) - lowerBound(out1, t); + const a01 = n0 > 0 ? d01 / n0 : 0, a02 = n0 > 0 ? d02 / n0 : 0, a12 = n1 > 0 ? d12 / n1 : 0; + [[d01, n0], [d02, n0], [d12, n1]].forEach(([d, r], k) => { + if (d > 0 && r > 0) { H[k] += d / r; V[k] += d / (r * r); cumhaz[keys[k]].push({ t, h: H[k], v: V[k] }); } + }); + const q0 = p0 * (1 - a01 - a02), q1 = p1 * (1 - a12) + p0 * a01; + p0 = q0; p1 = q1; + occupation.push({ t, p0, p1, p2: Math.max(0, 1 - p0 - p1) }); + } + return { n, prevalent, events, personTime: { s0, s1 }, occupation, cumhaz }; +} + +// --------------------------------------------------------------------------- +// Restricted mean survival time + +export interface Rmst { tau: number; rmst: number; se: number; lo: number; hi: number; n: number; events: number } + +/** + * The area under the Kaplan–Meier curve from 0 to `tau`, with the Greenwood-type standard error of + * that area (Klein & Moeschberger 4.5; R survRM2's rmst1): Var = Σ over event times t_j ≤ tau of + * A_j² d_j / (n_j (n_j − d_j)), A_j the area under the curve from t_j to tau, a term 0 where + * n_j = d_j. Same risk sets as kaplanMeier, delayed entry included. 95% interval on the mean's scale. + */ +export function rmst(data: SurvRecord[], tau: number): Rmst { + const clean = data.filter(d => Number.isFinite(d.t) && d.t >= 0).filter(observable).sort((x, y) => x.t - y.t); + const atRiskAt = riskSweep(clean); + const steps: { t: number; s: number; d: number; n: number }[] = []; + let s = 1, i = 0, events = 0; + while (i < clean.length && clean[i].t <= tau) { + const t = clean[i].t; + let d = 0; + while (i < clean.length && clean[i].t === t) { if (clean[i].event) d++; i++; } + const n = atRiskAt(t); + if (d > 0 && n > 0) { s *= 1 - d / n; steps.push({ t, s, d, n }); events += d; } + } + // the area after each event time, accumulated from tau backwards + let after = 0, v = 0; + for (let k = steps.length - 1; k >= 0; k--) { + const next = k + 1 < steps.length ? steps[k + 1].t : tau; + after += (next - steps[k].t) * steps[k].s; + const { d, n } = steps[k]; + if (n > d) v += after * after * d / (n * (n - d)); + } + const area = after + (steps.length ? steps[0].t : tau); + const se = Math.sqrt(v); + return { tau, rmst: area, se, lo: area - Z_975 * se, hi: area + Z_975 * se, n: clean.length, events }; +} + +/** The number at risk just before `t` (entered before it, not yet left), for each of `times`. */ +export function atRiskAtTimes(data: SurvRecord[], times: number[]): number[] { + const clean = data.filter(observable).sort((x, y) => x.t - y.t); + const order = times.map((t, k) => [t, k] as const).sort((a, b) => a[0] - b[0]); + const sweep = riskSweep(clean), out = new Array(times.length).fill(0); + for (const [t, k] of order) out[k] = sweep(t); + return out; +} diff --git a/lib/timeline-endpoints.ts b/lib/timeline-endpoints.ts new file mode 100644 index 0000000..b0d2ad9 --- /dev/null +++ b/lib/timeline-endpoints.ts @@ -0,0 +1,61 @@ +// What the views reading an endpoint off the timeline share: the progression event (its defaults, +// checks and words) and the check that the timeline and OS_MONTHS run on one clock. +import type { ProgressionEvent } from "./federation-views.ts"; + +/** the progression event as a view names it; every field optional */ +export interface ProgressionInput { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number } + +/** The progression event with its defaults: event type Diagnosis, key PROGRESSION, values Y / Yes (matched folded). */ +export function progressionEvent(raw: ProgressionInput | undefined): ProgressionEvent { + const pr0 = raw || {}; + return { + eventType: String(pr0.eventType || "Diagnosis").slice(0, 120), key: String(pr0.key || "PROGRESSION").slice(0, 120), + values: (pr0.values && pr0.values.length ? pr0.values : ["Y", "Yes"]).map((v) => String(v).slice(0, 80)), + ...(pr0.probabilityKey ? { probabilityKey: String(pr0.probabilityKey).slice(0, 120), minProbability: Number(pr0.minProbability) } : {}), + }; +} + +/** why a progression event cannot be used, or null */ +export function progressionError(p: ProgressionEvent): string | null { + if (p.values.length > 10) return "progression.values takes at most 10 values"; + if (p.probabilityKey && !Number.isFinite(p.minProbability)) return "progression.probabilityKey needs minProbability, the least probability counted as progression"; + return null; +} + +/** what makes an event a progression, in words: "PROGRESSION in Y / Yes and NLP_PROGRESSION_PROBABILITY ≥ 0.8" */ +export const progressionCondition = (p: ProgressionEvent): string => + p.key + " in " + p.values.join(" / ") + (p.probabilityKey ? " and " + p.probabilityKey + " ≥ " + p.minProbability : ""); + +/** a study whose timeline runs past OS follow-up for more than this share of its patients is refused */ +export const CLOCK_TOLERANCE = 0.05; + +/** the studies failing the clock check, from each study's count of late patients over those checked */ +export function clockFailures(byStudy: Map): [string, { late: number; anchored: number }][] { + return [...byStudy].filter(([, x]) => x.anchored > 0 && x.late / x.anchored > CLOCK_TOLERANCE); +} + +/** a treatment anchor as a view names it */ +export interface AnchorInput { eventType?: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" } +export interface TreatmentAnchor { eventType: string; key?: string; value?: string; occurrence: "FIRST" | "LAST" } + +/** + * The anchor whose start begins a patient's clock — a timeline event (optionally with key = value), + * its first occurrence unless LAST — and its words ("first Treatment [SUBTYPE=Immuno]"); or why it + * cannot be one. `what` names the view in the refusal ("a treatment-endpoints view"). + */ +export function treatmentAnchor(a0: AnchorInput | undefined, what: string): { anchor: TreatmentAnchor; anchoredTo: string } | { error: string } { + if (!a0 || typeof a0 !== "object" || !String(a0.eventType || "").trim()) + return { error: what + " needs anchor: {eventType, key?, value?} — the treatment whose start begins the clock, e.g. {eventType: \"Treatment\", key: \"SUBTYPE\", value: \"Immuno\"}" }; + if (!!a0.key !== (a0.value != null && a0.value !== "")) return { error: "anchor's key and value go together" }; + const anchor = { eventType: String(a0.eventType).trim().slice(0, 120), ...(a0.key ? { key: String(a0.key).slice(0, 120), value: String(a0.value).slice(0, 200) } : {}), + occurrence: a0.occurrence === "LAST" ? "LAST" as const : "FIRST" as const }; + const anchoredTo = (anchor.occurrence === "LAST" ? "last " : "first ") + anchor.eventType + (anchor.key ? " [" + anchor.key + "=" + anchor.value + "]" : ""); + return { anchor, anchoredTo }; +} + +/** the refusal when no cohort patient has the anchor, listing the values its key takes (rows of labTestsSql) */ +export function noAnchorMessage(anchor: TreatmentAnchor, vNames: string[], vals: Record[]): string { + return "no patient of " + (vNames.length === 1 ? "“" + vNames[0] + "”" : "these cohorts") + " has a " + anchor.eventType + " event" + + (anchor.key ? " with " + anchor.key + " = " + anchor.value : "") + + (vals.length ? ". " + anchor.key + " takes: " + vals.slice(0, 20).map((v) => String(v.test) + " (" + Number(v.patients).toLocaleString("en-US") + " patients)").join(", ") : ""); +} diff --git a/lib/types.ts b/lib/types.ts index 4a6da17..1e86909 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -542,6 +542,314 @@ export interface LogisticResult { /** studies whose patients were left out of the fit for lacking patient-level access */ excludedRemote?: { study: string; patients: number }[]; } +/** + * Pan-cancer forest: one exposure's hazard ratio within each stratum of the cohort (a cancer type, + * a study, or a cohort), each from its own Cox fit, pooled by a DerSimonian–Laird random-effects + * meta-analysis (and the fixed-effect estimate beside it). Fit from per-patient rows within each + * local study, the same boundary as `cox`. + */ +export interface ForestView { + note?: string; + kind: "forest"; cohorts?: string[]; + /** the exposure, as a plot axis: a gene's alteration (not profiled is left out, never unaltered), a numeric or categorical attribute */ + exposure: PlotAxis; + /** a categorical exposure's level counted as exposed (default for an alteration axis: any alteration) */ + exposed?: string; + /** a numeric exposure: exposed when the value is at least this */ + threshold?: number; + /** endpoint prefix (default OS), and the cox view's escape hatch for other column names */ + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** what a row is: a categorical attribute (default CANCER_TYPE), "study", or "cohort" (the view's cohorts) */ + strata?: string; + /** adjust each stratum's fit for these (default none) */ + covariates?: CoxCovariate[]; + /** a stratum with fewer patients or events than these is pooled into "Other" (defaults 30 and 10) */ + minPatients?: number; minEvents?: number; + /** at most this many strata are drawn, the largest; the rest pool into "Other" (default 30) */ + maxStrata?: number; + /** what happens to the small strata: pooled into one "Other" row (default) or left out, listed */ + small?: "pool" | "drop"; +} +export interface ForestRow { + label: string; n: number; events: number; exposed: number; exposedEvents: number; + beta: number; se: number; hr: number; ciLow: number; ciHigh: number; p: number; + /** the row's share of the random-effects weight */ + weight: number; + /** an "Other" row: the strata it pools */ + pooled?: string[]; +} +export interface ForestPooled { hr: number; ciLow: number; ciHigh: number; p: number; beta: number; se: number } +export interface ForestResult { + exposure: string; exposedLabel: string; endpoint: string; strata: string; covariates: string[]; + rows: ForestRow[]; + /** strata that are not rows, and why */ + left: { label: string; n: number; events: number; reason: string }[]; + /** pooled estimates; null with fewer than two rows */ + fixed: ForestPooled | null; random: ForestPooled | null; + tau2: number; q: number; qDf: number; qP: number; i2: number; + /** patients with a time, the exposure and the stratum (and every covariate) */ + n: number; events: number; + notProfiled: number; missing: number; + excludedRemote?: { study: string; patients: number }[]; +} +/** + * Covariate-adjusted alteration enrichment between two cohorts: for every gene mutated in at least + * a minimum share of the patients profiled for it, a logistic regression of altered on cohort and + * covariates, reported as the adjusted odds ratio for the first cohort beside the raw one. Fit from + * per-patient rows inside each local study (patient-level participants only). + */ +export interface AdjustedView { + note?: string; + kind: "adjusted"; cohorts?: string[]; + /** clinical attributes adjusted for (default none): a number as recorded, a category against its commonest value */ + covariates?: string[]; + /** count every called mutation (default), or only OncoKB drivers */ + measure?: "any" | "driver"; + /** genes tested: mutated in at least this share of the patients profiled for them, both cohorts together (default 0.05) */ + minShare?: number; + /** and in at least this many patients (default 10) */ + minAltered?: number; + /** genes named on the volcano (default 12) */ + label?: number; +} +export interface AdjustedGene { + gene: string; + /** per cohort, in the view's order: patients altered, and patients profiled for the gene with every covariate */ + altered: [number, number]; profiled: [number, number]; + /** unadjusted odds ratio (null with an empty cell) and its two-sided Fisher p */ + rawOr: number | null; fisherP: number; + /** the cohort term's adjusted odds ratio, Wald 95% CI and p; q is Benjamini–Hochberg over the genes fit */ + or: number; ciLow: number; ciHigh: number; p: number; q: number; + /** a covariate category that would separate was merged into its reference for this gene */ + merged?: boolean; +} +export interface AdjustedResult { + groups: [string, string]; covariates: string[]; measure: "any" | "driver"; minShare: number; minAltered: number; + /** per cohort: patients with a mutation panel, and of those with every covariate (the ones fit) */ + patients: { cohort: string; withPanel: number; used: number }[]; + /** genes fit, by adjusted p */ + rows: AdjustedGene[]; + /** genes that passed the share threshold but could not be fit, and why */ + failed: { gene: string; reason: string }[]; + significant: number; + labels: string[]; + excludedRemote?: { study: string; patients: number }[]; +} +/** + * Clinical actionability from the version-pinned OncoKB store: each sequenced patient's highest + * sensitive therapeutic level (1, 2, 3A, 3B, 4) and any resistance level (R1, R2) over their + * alterations, as shares per cohort (optionally per group of an attribute), and for patients with + * a sample on each side of `pairBy` the concordance of their highest level between the two. Counts only. + */ +export interface ActionabilityView { + note?: string; + kind: "actionability"; cohorts?: string[]; + /** split each cohort by a categorical attribute (e.g. CANCER_TYPE); default none */ + groupBy?: string; + /** with groupBy, the largest groups drawn (default 8); the rest are pooled into a disclosed "Other" */ + top?: number; + /** the two sides compared for concordance (default SAMPLE_TYPE Primary against Metastasis); null turns it off */ + pairBy?: { attribute: string; a: string[]; b: string[] } | null; +} +/** patients per highest level: [1, 2, 3A, 3B, 4, none], and with a resistance level [R1, R2] */ +export interface ActionLevels { sequenced: number; levels: number[]; resistance: number[] } +export interface ActionabilityPanel extends ActionLevels { + cohort: string; + /** the cohort's patients, sequenced or not */ + patients: number; + groups?: (ActionLevels & { label: string })[]; + /** groups past `top`, pooled into the last group */ + pooled?: string[]; + /** patients with a sequenced sample on each side: table[a][b] over the six highest levels, and how many differ */ + concordance?: { pairs: number; table: number[][]; discordant: number }; +} +export interface ActionabilityResult { + pin: string; levels: string[]; groupBy: string | null; top: number; + pairBy: { attribute: string; a: string[]; b: string[] } | null; + panels: ActionabilityPanel[]; + /** the alteration types the store has levels for at this pin */ + storeTypes: string[]; + excluded?: { study: string; reason: string }[]; +} +/** + * Driver-negative analysis: one cohort split into patients with an OncoKB driver in a canonical + * driver list, patients with none who were profiled for every listed gene, and those not fully + * profiled (neither); then every other gene's alteration frequency compared between the first two. + * Counts only. + */ +export interface DrivernegativeView { + note?: string; + kind: "drivernegative"; cohorts?: string[]; + /** the canonical drivers (default the NSCLC set: EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1–3) */ + drivers?: string[]; + /** what is compared between the groups (default mutation; sv counts either fusion partner) */ + alteration?: "mutation" | "sv" | "cna"; + /** count every alteration (default), or only OncoKB drivers */ + measure?: "any" | "driver"; + /** genes altered in fewer patients than this, both groups together, are not tested (default 5) */ + minAltered?: number; + /** genes named on the volcano (default 12) */ + label?: number; +} +export interface DrivernegativeGene { + gene: string; cytoband?: string; + /** [driver-negative, driver-positive] patients altered, and profiled for the gene */ + altered: [number, number]; profiled: [number, number]; + log2Ratio: number | null; p: number; q: number; +} +export interface DrivernegativeResult { + drivers: string[]; alteration: string; measure: "any" | "driver"; minAltered: number; + /** the cohort's patients, those sequenced for mutations, and the three groups of those */ + patients: number; sequenced: number; positive: number; negative: number; unprofiled: number; + tested: number; untested: number; significant: number; + /** the most significant genes (≤300) */ + rows: DrivernegativeGene[]; + labels: string[]; + excluded?: { study: string; reason: string }[]; +} +/** + * Metastatic organotropism: for each organ a cohort's patients metastasised to and each gene, + * whether the gene's alteration goes with metastasis there, stratified by cancer type + * (Cochran–Mantel–Haenszel); with dated sites on the timeline, the cumulative incidence of + * metastasis to one site by one gene's status, death competing. Counts only. + */ +export interface OrganotropismView { + note?: string; + kind: "organotropism"; cohorts?: string[]; + /** where the sites come from (default auto: per-organ flags, else the timeline, else a biopsy-site attribute) */ + source?: "auto" | "flags" | "timeline" | "sample"; + /** flags: the per-organ Yes/No attributes' prefix (default DMETS_DX_, MSK-MET's) */ + flagPrefix?: string; + /** timeline: the event type and key whose value names a site (default Diagnosis / TUMOR_SITE) */ + timeline?: { eventType?: string; key?: string }; + /** sample: the biopsy-site attribute of metastasis samples (default METASTATIC_SITE), mapped to organs by our disclosed rules */ + siteAttribute?: string; + /** only these sites (labels as the result names them); default every site with enough patients */ + sites?: string[]; + /** the genes (at most 20); default the `top` most altered in the cohort */ + genes?: string[]; + /** how many most-altered genes when `genes` is not given (default 12, at most 20) */ + top?: number; + /** what counts as altered (default mutation) and whether only OncoKB drivers count (default driver) */ + alteration?: "mutation" | "cna" | "amp" | "homdel" | "sv"; + measure?: "any" | "driver"; + /** the stratifying covariate (default CANCER_TYPE); null for none */ + stratifyBy?: string | null; + /** a cell is tested only with at least this many altered patients and this many with the site (default 5) */ + minPatients?: number; + /** timeline source: the cumulative-incidence panel's site and gene (default the grid's most significant cell); null for none */ + incidence?: { site?: string; gene?: string } | null; +} +export interface OrganotropismSite { site: string; label: string; evaluable: number; withSite: number } +export interface OrganotropismCell { + site: string; gene: string; + /** patients evaluable for the site and profiled for the gene, pooled over strata: with the site (and of them altered), without it (and altered) */ + withSite: number; withAltered: number; without: number; withoutAltered: number; + /** Mantel–Haenszel common odds ratio over the strata, its log2, CMH chi-squared p and BH q; null when not tested */ + or: number | null; log2Or: number | null; p: number | null; q: number | null; strata: number; +} +export interface OrganotropismIncidence { + site: string; gene: string; + curves: IncidenceCurve[]; + horizons: { months: number; cells: { cohort: string; f1: number; lo: number; hi: number; atRisk: number }[] }[]; + gray?: import("./stats.ts").GrayTest; + /** patients whose first site event came after their last follow-up (the clock check) */ + late: number; records: number; +} +export interface OrganotropismResult { + source: "flags" | "timeline" | "sample"; sourceLabel: string; + alteration: string; measure: "any" | "driver"; stratifyBy: string | null; minPatients: number; + patients: number; + sites: OrganotropismSite[]; + genes: { gene: string; altered?: number }[]; + /** every (site, gene) cell, tested or not */ + cells: OrganotropismCell[]; + tested: number; significant: number; + /** sample source: the biopsy-site values counted as "Other", with their patients */ + otherValues?: { value: string; patients: number }[]; + incidence?: OrganotropismIncidence; + /** why there is no incidence panel */ + noIncidence?: string; + excluded?: { study: string; reason: string }[]; +} +/** + * A published molecular classifier applied per patient as derived subtypes — the endometrial + * classification (TCGA 2013 / ProMisE / WHO 2020): POLE-ultramutated, else MMR-deficient (MSI-high), + * else p53-abnormal, else no specific molecular profile, with "not classifiable" where an input is + * missing. Per cohort: subtype shares, subtype by an attribute, and OS by subtype. Counts only. + */ +export interface MolsubtypeView { + note?: string; + kind: "molsubtype"; cohorts?: string[]; + /** the classifier (only "endometrial" so far) */ + classifier?: "endometrial"; + /** the MSI call: a categorical attribute's MSI-high and stable values, or a numeric score's cut-offs (default MSI_TYPE Instable / Stable, else MSI_SCORE ≥ 10 / < 3) */ + msi?: { attribute?: string; high?: string[]; stable?: string[]; threshold?: number; stableBelow?: number }; + /** an abnormal p53: an OncoKB-driver TP53 mutation (default) or any protein-changing TP53 mutation */ + p53?: "driver" | "nonsynonymous"; + /** a patient attribute to cross the subtypes with (e.g. ANCESTRY_LABEL, STAGE_HIGHEST_RECORDED) */ + by?: string; + /** groups of `by` drawn (largest first; default 8), the rest pooled into a named Other */ + top?: number; + /** draw OS by subtype (default true) */ + survival?: boolean; +} +export interface MolsubtypeCounts { counts: number[]; classified: number; notClassifiable: number; why: Record } +export interface MolsubtypePanel extends MolsubtypeCounts { + cohort: string; patients: number; + groups?: (MolsubtypeCounts & { label: string })[]; + /** cohort's classified patients by group: chi-squared over group × subtype */ + byTest?: import("./stats.ts").ChiSquareTest; + pooled?: string[]; +} +export interface MolsubtypeResult { + classifier: string; classifierLabel: string; + /** the subtypes in hierarchy order */ + subtypes: string[]; + msi: string; p53: string; poleHotspots: string[]; + by: string | null; + panels: MolsubtypePanel[]; + /** classified patients' subtype shares between cohorts (two or more) */ + cohortTest?: import("./stats.ts").ChiSquareTest; + /** OS by subtype, per cohort (classified patients with OS_MONTHS) */ + survival?: { cohort: string; curves: (Omit & { subtype: string })[]; logrank: LogRankK | null; noTest?: string }[]; + noSurvival?: string; + excluded?: { study: string; reason: string }[]; +} +/** + * Germline carriers and their somatic second hits: per gene, the patients with a germline call, + * their share of the patients sequenced for the gene, and how often the tumour carries a second, + * somatic hit in the same gene, against the somatic rate in non-carriers; optionally OS by carrier + * status for one gene. Patient-level counts only. + */ +export interface GermlineView { + note?: string; + kind: "germline"; cohorts?: string[]; + /** the genes (at most 60); default every gene with at least minCarriers carriers in a cohort */ + genes?: string[]; + minCarriers?: number; + /** draw OS for one gene's carriers against non-carriers */ + survival?: { gene: string }; +} +export interface GermlineCounts { + /** patients sequenced for the gene (a mutation panel listing it, or WES), carriers among them, and carriers whose panel does not list it (left out) */ + sequenced: number; carriers: number; outsidePanel: number; + carrierSomatic: number; carrierCnaProfiled: number; carrierDeletion: number; carrierEither: number; + noncarriers: number; noncarrierSomatic: number; noncarrierCnaProfiled: number; noncarrierDeletion: number; noncarrierEither: number; +} +export interface GermlineGene extends GermlineCounts { + gene: string; + /** Fisher's exact test of carriers' second-hit rate against non-carriers' somatic rate, and BH q */ + p: number | null; q: number | null; +} +export interface GermlineResult { + genes: string[]; minCarriers: number; chosen?: string; + panels: { cohort: string; patients: number; genes: GermlineGene[] }[]; + survival?: { cohort: string; gene: string; curves: (Omit & { group: string })[]; logrank: LogRankK | null; noTest?: string }[]; + /** what was done about loss of heterozygosity */ + loh: string; + excluded?: { study: string; reason: string }[]; +} /** * A plot: two axes over the same cases. The shape — scatter, box, contingency — follows from * what the axes are, exactly as it does in the portal, rather than being asked for. @@ -893,6 +1201,205 @@ export interface PairedResult { panels: PairedPanel[]; excluded?: { study: string; reason: string }[]; } +/** + * Alterations acquired under therapy: patients with a dated sample on or before a treatment start + * and another after it, per gene, altered after but not before (acquired), before but not after + * (lost), on both (shared) or neither — among patients whose two samples' panels both cover the gene. + */ +export interface AcquiredView { + note?: string; + kind: "acquired"; cohorts?: string[]; + /** the treatment start: the FIRST event of this type, with key = value when given (default Treatment, any) */ + anchor?: { eventType: string; key?: string; value?: string }; + /** the genes to test (at most 30); omitted or empty scans every gene altered in at least `minPost` after-samples */ + genes?: string[]; + /** a scan: the fewest patients altered after treatment for a gene to be tested (default 3) */ + minPost?: number; + /** a scan: how many genes to draw, most significant first (default 20, at most 40) */ + top?: number; + /** the before-sample is the last on or before the start, at most this many days before it (default: any) */ + preWindowDays?: number; + /** the after-sample is the first at least this many days after the start (default 30) */ + minGapDays?: number; + /** the timeline event types that date a sample (default Sample acquisition, Sequencing, Specimen) and the attribute naming it (default SAMPLE_ID) */ + sampleEvents?: string[]; sampleKey?: string; + /** any alteration (default) or OncoKB oncogenic only */ + measure?: "driver" | "any"; +} +/** what the clonal-haematopoiesis filter removed from a cohort's plasma calls, and the headline numbers without it */ +export interface CtdnaChPanel { + /** plasma mutation calls, those with matched-normal counts loaded, removed by gene / by the normal / by either, and the patients with a removed call */ + calls: number; withNormal: number; byGene: number; byNormal: number; removed: number; patients: number; + /** per gene, the calls and patients removed */ + genes: { gene: string; calls: number; patients: number; byGene: number; byNormal: number }[]; + /** without the filter */ + before: { + detected: number; detectedLow: number | null; detectedHigh: number | null; + genes: { gene: string; both: number; plasmaOnly: number; ppa: number | null }[]; + survival: { curves: { label: string; n: number; events: number; median: number | null }[]; p: number | null } | null; + }; +} +export interface AcquiredGene { + gene: string; + /** patients whose before- and after-sample both cover the gene */ + pairs: number; acquired: number; lost: number; shared: number; neither: number; + /** exact McNemar p on acquired vs lost, and BH q across the genes tested in this cohort */ + p: number; q: number; +} +export interface AcquiredPanel { + cohort: string; + /** the funnel: anchored, with a dated sample, with one before, with one after, with both */ + anchored: number; dated: number; withPre: number; withPost: number; patients: number; + /** among the pairs: median days from the before-sample to the start, from the start to the after-sample, and the pairs' spacing (quartiles) */ + preDays: number | null; postDays: number | null; gapDays: { q1: number; median: number; q3: number } | null; + genes: AcquiredGene[]; + /** a scan: the genes tested (altered after in at least minPost patients), which q is taken across */ + tested?: number; +} +export interface AcquiredResult { + anchoredTo: string; + preWindowDays: number | null; minGapDays: number; + sampleEvents: string[]; sampleKey: string; + measure: "driver" | "any"; + scan?: { top: number; minPost: number }; + panels: AcquiredPanel[]; + excluded?: { study: string; reason: string }[]; +} +/** + * ctDNA against tissue: patients with a plasma (cfDNA) sample and a tissue sample, per gene covered + * by both, altered in both, tissue only, plasma only or neither; plasma's positive percent + * agreement with tissue as the reference; the share of plasma patients with any alteration + * detected; and overall survival by ctDNA detected or not. + */ +export interface CtdnaView { + note?: string; + kind: "ctdna"; cohorts?: string[]; + /** the genes (at most 30); omitted, the `top` (default 15, at most 30) most altered in the paired patients */ + genes?: string[]; top?: number; + /** the sample attribute classing samples (default SAMPLE_CLASS) and its plasma (default cfDNA, ctDNA) and tissue (default Tumor) values */ + attribute?: string; plasma?: string[]; tissue?: string[]; + /** samples on these panels count as plasma whatever the attribute says (e.g. ["ACCESS129"]) */ + plasmaPanels?: string[]; + /** keep only samples the timeline dates within this many days of a sample of the other kind (default: no dates needed) */ + withinDays?: number; + sampleEvents?: string[]; sampleKey?: string; + /** any alteration (default) or OncoKB oncogenic only */ + measure?: "driver" | "any"; + /** + * leave out plasma mutation calls likely from clonal haematopoiesis: true for the defaults, or + * `genes` (default the canonical CH genes; [] for none) and `normal` (default true: calls whose + * matched white-cell sample has at least `normalMinAlt`, default 2, alternate reads at an allele + * fraction at least `normalMinRatio`, default 0.3, of the plasma's) + */ + chFilter?: boolean | { genes?: string[]; normal?: boolean; normalMinAlt?: number; normalMinRatio?: number }; +} +export interface CtdnaGene { + gene: string; + /** patients whose plasma and tissue samples both cover the gene */ + pairs: number; both: number; tissueOnly: number; plasmaOnly: number; neither: number; + /** positive percent agreement: both ÷ altered in tissue, with its 95% Wilson interval (null when tissue has none) */ + ppa: number | null; ppaLow: number | null; ppaHigh: number | null; + /** (both + neither) ÷ pairs */ + concordance: number | null; +} +export interface CtdnaPanel { + cohort: string; + /** patients with a plasma sample, a tissue sample, both; samples of each kind among those with both */ + plasmaPatients: number; tissuePatients: number; patients: number; plasmaSamples: number; tissueSamples: number; + /** with withinDays: before the date rule, patients with plasma and tissue; and the classed samples the timeline dated */ + undated?: { plasmaPatients: number; tissuePatients: number; datedSamples: number }; + /** of the plasma patients, those with any (driver) alteration called in plasma, with a 95% Wilson interval */ + detected: number; detectedLow: number | null; detectedHigh: number | null; + genes: CtdnaGene[]; + /** overall survival of the plasma patients by ctDNA detected or not; null with why when not drawn */ + survival: { curves: (Omit & { label: string })[]; logrank: LogRank | null; missing: number } | null; + survivalWhy?: string; + /** with chFilter */ + ch?: CtdnaChPanel; +} +export interface CtdnaResult { + attribute: string; plasma: string[]; tissue: string[]; plasmaPanels: string[]; + withinDays: number | null; + measure: "driver" | "any"; + /** present when the genes were chosen as the most altered */ + top?: number; + panels: CtdnaPanel[]; + excluded?: { study: string; reason: string }[]; + /** with chFilter: the rule applied */ + chFilter?: { genes: string[]; normalMinAlt: number | null; normalMinRatio: number }; +} +/** + * Mutual exclusivity and co-occurrence against each tumour's own alteration burden (DISCOVER, + * Canisius et al. 2016): per sample and gene a background probability fitted from the sample and + * gene marginals of the whole altered-gene matrix, so that heavily altered tumours are expected to + * share alterations; each pair's co-altered count tested against its Poisson-binomial null. + */ +export interface ExclusivityView { + note?: string; + kind: "exclusivity"; cohorts?: string[]; + /** 2 to 20 genes */ + genes: string[]; + /** estimate the background within each value of this sample attribute (e.g. CANCER_TYPE) */ + stratifyBy?: string; + /** any alteration (default) or OncoKB oncogenic only */ + measure?: "driver" | "any"; +} +export interface ExclusivityPair { + a: string; b: string; + /** samples profiled for both genes, those altered in both, and the background's expected number */ + profiled: number; both: number; expected: number; + /** DISCOVER: lower tail (exclusivity) and upper tail (co-occurrence), BH q in each direction */ + pExclusive: number; qExclusive: number; pCooccur: number; qCooccur: number; + /** false when the tails came from the refined normal approximation, not the exact distribution */ + exact: boolean; + /** the plain two-sided Fisher test on the same samples' 2×2, BH across the pairs */ + onlyA: number; onlyB: number; neither: number; fisherP: number; fisherQ: number; log2Odds: number | null; +} +export interface ExclusivityResult { + cohort: string; genes: string[]; + stratifyBy: string | null; strata: { name: string; samples: number }[]; + measure: "driver" | "any"; + /** samples with a panel, the genes in the background matrix, and how the fit ended */ + samples: number; matrixGenes: number; iterations: number; converged: boolean; maxResidual: number; + pairs: ExclusivityPair[]; + /** pairs no sample was profiled for both genes of */ + unassessable: string[]; + excluded?: { study: string; reason: string }[]; +} +/** + * Genomic instability from copy-number segments (hg19): per sample, the fraction of the genome + * altered and arm-level gains and losses, whose count is the aneuploidy score (Taylor et al. 2018); + * per cohort their distributions, a rank test between cohorts, and each arm's gain/loss frequency. + */ +export interface InstabilityView { + note?: string; + kind: "instability"; cohorts?: string[]; + /** |log2 segment mean| at or past which a segment is gained or lost (default 0.2) */ + threshold?: number; + /** an arm is called when more than this share of its length is past the threshold (default 0.5) */ + armShare?: number; + /** score chromosome X too (default false: its log ratio mostly records sex) */ + includeX?: boolean; +} +export interface InstabilitySpread { p5: number | null; q1: number | null; median: number | null; q3: number | null; p95: number | null; mean: number | null } +export interface InstabilityPanel { + cohort: string; + /** the cohort's samples, those with segments, and those with every scored arm measured */ + samples: number; segmented: number; allArms: number; + /** samples by aneuploidy score (index = arms altered), and by FGA in 1-point bins (index = percent) */ + score: number[]; fga: number[]; + scoreSpread: InstabilitySpread; fgaSpread: InstabilitySpread; + /** per arm (in `arms` order): the samples that measured it, and those gaining or losing it */ + arms: { arm: string; measured: number; gain: number; loss: number }[]; +} +export interface InstabilityResult { + build: "hg19"; threshold: number; armShare: number; includeX: boolean; + arms: string[]; + panels: InstabilityPanel[]; + /** two cohorts: Mann–Whitney; more: Kruskal–Wallis — on the aneuploidy score and on FGA */ + test: { name: "Mann–Whitney U" | "Kruskal–Wallis"; scoreP: number; fgaP: number } | null; + excluded?: { study: string; reason: string }[]; +} /** * Clonality from FACETS allele-specific copy number: for each gene, whether the cohort's * mutation calls are clonal or subclonal, how often the mutated locus has lost its other allele, @@ -1013,6 +1520,28 @@ export interface RiskmodelView { * computes it; a list left out is the default (the study's leading attributes; the genes the * model kept most often) */ describe?: { attributes?: string[]; genes?: string[] }; + /** external validation: another cohort of the document, scored with the fitted ensemble's mean + * coefficients and cut at the training risk-group thresholds */ + validateOn?: string; +} + +/** A risk model scored on another cohort it never saw (RiskmodelView.validateOn). */ +export interface RiskExternal { + cohort: string; study: string; + /** patients scored, their events, and those left out with why */ + n: number; events: number; + cohortPatients: number; rowsReturned: number; + notProfiled: number; noStratum: number; noAnchor: number; anchorAfterStart: number; noFollowUp: number; + /** patients whose panels do not cover every gene the model weighs: never scored as wild-type */ + notCovered: number; + /** the model's genes with a non-zero ensemble coefficient, which every scored patient's panels cover */ + modelGenes: number; + /** Harrell's C-index of the score (within strata), with a seeded bootstrap 95% interval */ + cIndex: number; cIndexLo: number; cIndexHi: number; bootstrap: number; + /** the training thresholds (raw scores) the groups are cut at */ + cuts: number[]; + groups: { name: string; curve: Omit; hr: number | null; ciLow: number | null; ciHigh: number | null; p: number | null }[]; + logrank: LogRankK | null; } /** Why a Table 1 row differs between the risk groups by construction, or is the model's stratum. */ @@ -1105,6 +1634,282 @@ export interface RiskmodelResult { percentiles: number[]; /** with `describe`: Table 1, the groups' makeup */ table1?: RiskTable1; + /** with `validateOn`: the model scored on that cohort */ + external?: RiskExternal; +} + +export interface TxendpointsView { + note?: string; + kind: "txendpoints"; cohorts?: string[]; + /** the clock's start: a treatment record (event type, key = value), its first occurrence unless LAST */ + anchor: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** ttnt time to next treatment (default), tot time on treatment, rwpfs real-world progression-free survival */ + endpoint?: "ttnt" | "tot" | "rwpfs"; + /** treatment lines (lib/treatment-lines.ts): days without therapy that start a new line (default 90), and which records form lines */ + lineGap?: number; lineClasses?: "systemic" | "all" | string[]; + /** rwpfs: the progression event (default event type Diagnosis, key PROGRESSION, values Y / Yes), and an optional probability floor */ + progression?: { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number }; + /** tot: a living patient whose stop lies within this many days of the last follow-up is censored as still on treatment (default 30) */ + ongoingDays?: number; + /** administrative censoring, months */ + cutoffMonths?: number; +} +export interface TxendpointsCurve extends Omit { + cohort: string; + /** the median's 95% interval (Greenwood, log scale), null where the band never reaches 0.5 */ + medianLo: number | null; medianHi: number | null; + /** the cohort's patients, those anchored, and those left out with why */ + cohortPatients: number; anchored: number; + excluded: { noAnchor: number; noFollowup: number; noStop: number }; + /** patients whose timeline runs past their last follow-up (the clock check) */ + late: number; +} +export interface TxendpointsResult { + endpoint: "ttnt" | "tot" | "rwpfs"; endpointLabel: string; anchoredTo: string; + lineGap: number; lineClasses: string; + progression?: { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number }; + ongoingDays?: number; cutoffMonths?: number; + curves: TxendpointsCurve[]; + /** k-group log-rank over the cohorts, stratified by study where several answer */ + logrank: LogRankK | null; + /** why there is no test */ + noTest?: string; + excludedStudies?: { study: string; reason: string }[]; +} + +export interface MultistateView { + note?: string; + kind: "multistate"; cohorts?: string[]; + /** the progression event (default event type Diagnosis, key PROGRESSION, values Y / Yes), and an optional probability floor */ + progression?: { eventType?: string; key?: string; values?: string[]; probabilityKey?: string; minProbability?: number }; + /** patients with a progression on or before day 0: start in the progression state (include, default) or are left out (exclude) */ + prevalent?: "include" | "exclude"; + /** months at which the state occupation is tabled (default [12, 24, 60]) */ + horizons?: number[]; + /** administrative censoring, months */ + cutoffMonths?: number; +} +/** a transition of the illness–death model: 01 start → progression, 02 start → death, 12 progression → death */ +export type MultistateTransitionKey = "01" | "02" | "12"; +export interface MultistateRate { + key: MultistateTransitionKey; events: number; personMonths: number; + /** events per 100 person-months in the state left, with its 95% interval (log scale, Poisson) */ + rate: number; lo: number | null; hi: number | null; +} +export interface MultistateCohort { + cohort: string; + /** the cohort's patients; those in the model (n), and of them those starting in the progression state */ + cohortPatients: number; n: number; prevalent: number; + excluded: { noFollowup: number; prevalent: number }; + /** in the model: patients with no record of the progression key at all, a progression only on or after the end of follow-up, a Sequencing event on day 0, a timeline past follow-up */ + notAssessed: number; afterEnd: number; seq0: number; late: number; + /** censored in the start and in the progression state */ + censored: { start: number; progression: number }; + rates: MultistateRate[]; + /** Aalen–Johansen state occupation: p0 alive without progression, p1 alive after progression, p2 dead */ + occupation: { t: number; p0: number; p1: number; p2: number }[]; + /** Nelson–Aalen cumulative hazard per transition, with its 95% interval (log scale) */ + cumhaz: Record; + /** curves are drawn to here: the last time at least 10 of the cohort's patients are still followed */ + drawnTo: number; + /** occupation at each horizon, with the number still followed; null past drawnTo */ + atHorizon: { months: number; p0: number | null; p1: number | null; p2: number | null; atRisk: number }[]; +} +export interface MultistateCox { + key: MultistateTransitionKey; + /** each cohort's hazard ratio against the first (a Cox model per transition, stratified by study) */ + rows: { cohort: string; hr: number; lo: number; hi: number; p: number }[]; + n: number; events: number; + /** why it was not fitted */ + noFit?: string; +} +export interface MultistateResult { + progression: { eventType: string; key: string; values: string[]; probabilityKey?: string; minProbability?: number }; + prevalent: "include" | "exclude"; horizons: number[]; cutoffMonths?: number; + /** the figures' time axis end: the latest cohort's drawnTo */ + tMax: number; + /** "first sequencing" when day 0 is a Sequencing event for most patients, else "the timeline's day 0" */ + origin: string; + cohorts: MultistateCohort[]; + /** reference cohort for the hazard ratios */ + reference: string; + cox: MultistateCox[]; + excludedStudies?: { study: string; reason: string }[]; +} + +export interface RmstView { + note?: string; + kind: "rmst"; cohorts?: string[]; + /** the endpoint (default OS), its _MONTHS/_STATUS columns or monthsAttribute/statusAttribute */ + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** start the clock at a treatment record instead of day 0 (overall survival from it; txendpoints' anchor and clock check) */ + anchor?: { eventType: string; key?: string; value?: string; occurrence?: "FIRST" | "LAST" }; + /** the restriction time, months; default the largest time at which every cohort still has 10 at risk */ + tau?: number; + /** landmarks, months: each keeps the patients still followed then and restarts the clock there (default [0]) */ + landmarkMonths?: number[]; +} +export interface RmstCurve extends Omit { + cohort: string; + /** restricted mean survival time to tau, its standard error and 95% interval, months */ + rmst: number; se: number; lo: number; hi: number; + /** the number at risk just before tau */ + atRiskTau: number; + /** patients dropped at the landmark: died or censored before it */ + beforeLandmark: number; +} +export interface RmstComparison { + cohort: string; vs: string; + /** RMST difference (cohort − vs) and ratio (cohort / vs), each with a 95% interval and a two-sided p */ + diff: number; diffLo: number; diffHi: number; diffP: number; + ratio: number; ratioLo: number; ratioHi: number; ratioP: number; +} +export interface RmstPanel { + landmark: number; tau: number; + /** whether tau was set in the view or is the default (every cohort ≥ 10 at risk) */ + tauSet: boolean; + curves: RmstCurve[]; + comparisons: RmstComparison[]; +} +export interface RmstResult { + endpointLabel: string; anchoredTo?: string; + panels: RmstPanel[]; + /** per cohort: its patients, and those without follow-up (or without the anchor) */ + cohorts: { cohort: string; cohortPatients: number; n: number; noFollowup: number; noAnchor: number }[]; + excludedStudies?: { study: string; reason: string }[]; +} + +export interface PredictiveView { + note?: string; + kind: "predictive"; cohorts?: string[]; + /** the exposure: patients with a timeline event of this type (and key = value) are exposed, the rest unexposed */ + exposure: { eventType: string; key?: string; value?: string }; + /** the candidate biomarkers (at most 20); each is fitted alone, over the patients profiled for it */ + genes: string[]; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** "any" mutation (default) or OncoKB drivers only */ + measure?: "any" | "driver"; + /** clinical attributes adjusted for (at most 6): numbers as they are, categories as indicators against the commonest level */ + covariates?: string[]; + /** a gene is fitted only with at least this many mutated patients in each exposure group (default 5) */ + minMutated?: number; +} +export interface PredictiveEffect { hr: number; ciLow: number; ciHigh: number; p: number } +export interface PredictiveGene { + gene: string; + /** patients profiled for the gene (and with every covariate), by exposure, and those of them mutated */ + exposed: number; unexposed: number; mutatedExposed: number; mutatedUnexposed: number; events: number; + /** the gene's hazard ratio among the exposed and among the unexposed, and their ratio (the interaction); null when not fitted */ + inExposed: PredictiveEffect | null; inUnexposed: PredictiveEffect | null; interaction: PredictiveEffect | null; + /** Benjamini–Hochberg over the fitted genes' interaction p */ + q: number | null; + /** why the gene was not fitted */ + skipped?: string; +} +export interface PredictiveResult { + exposure: string; endpoint: string; measure: "any" | "driver"; + /** patients with a survival time and every covariate, and how many of them are exposed */ + n: number; exposed: number; events: number; + /** patients left out: no mutation panel on record, a covariate missing */ + notProfiled: number; noCovariate: number; + covariates: string[]; minMutated: number; + /** the fits' baseline hazard is stratified by these (cohort and study, when several) */ + strata: number; + genes: PredictiveGene[]; +} + +export interface CutpointView { + note?: string; + kind: "cutpoint"; cohorts?: string[]; + /** the numeric marker (default TMB_NONSYNONYMOUS): the patient's value, or a sample attribute's value on the patient's first sample */ + attribute?: string; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** permutations of the marker for the corrected p (default 1000, 100 to 5000), and their seed (default 1) */ + permutations?: number; seed?: number; + /** a categorical attribute: a cut-point per stratum (its largest values, at most 8, each with at least minStratum patients) */ + stratifyBy?: string; minStratum?: number; +} +export interface CutpointScan { + /** patients with a survival time and a marker value, and their events */ + n: number; events: number; + /** the chosen cut: high is marker > cut */ + cut: number; + /** the share of patients at or below it */ + lowShare: number; + /** the maximally selected standardized log-rank statistic (z², chi-squared scale) at the cut, and its permutation-corrected p */ + maxStat: number; permP: number; + /** the ordinary log-rank test of the two groups at the cut, as if the cut had been fixed in advance */ + naiveChi2: number; naiveP: number; + /** the 95th percentile of the permutation distribution of the maximum: the statistic a cut must pass to be significant at 0.05 */ + critical95: number; + /** high against low, Cox */ + hr: { hr: number; ciLow: number; ciHigh: number; p: number } | null; + low: { n: number; events: number; median: number | null }; high: { n: number; events: number; median: number | null }; + /** candidate cuts scanned and the statistic at each */ + candidates: { cut: number; stat: number }[]; +} +export interface CutpointResult extends CutpointScan { + attribute: string; endpoint: string; permutations: number; seed: number; + /** cohort patients with a survival time but no marker value */ + noValue: number; + curves: { low: Omit; high: Omit }; + strata?: { + attribute: string; minStratum: number; + rows: ({ stratum: string } & Omit)[]; + /** strata not scanned: too small, past the eighth, or no value */ + omitted: { values: number; patients: number }; + }; +} + +/** + * HLA class I genotype and HLA loss of heterozygosity against outcome (Chowell et al. 2018; + * McGranahan et al. 2017): per cohort, the share of fully typed patients homozygous at 0, 1 or + * 2+ of HLA-A, -B and -C, the share with HLA LOH in a tumour sample, and overall survival by + * homozygous-at-any-locus against fully heterozygous and by HLA LOH against none. + */ +export interface HlaView { + note?: string; + kind: "hla"; cohorts?: string[]; + /** fields of the allele name compared: 1 (allele group, A*02), 2 (protein, A*02:01; default) or "full" */ + resolution?: 1 | 2 | "full"; + /** per locus, the attributes holding its two alleles ([first, second]) or one holding both ("A*02:01/A*24:02"); default: the layouts MSK-IMPACT and the melanoma/NSCLC immunotherapy studies use */ + alleles?: { A?: string[]; B?: string[]; C?: string[] }; + /** sample attributes calling HLA LOH (default HLA_A1_LOH … HLA_C2_LOH, HLA_LOH) and the values that mean loss (default Loss, Yes, LOH, True) */ + lohAttributes?: string[]; lohValues?: string[]; + endpoint?: string; monthsAttribute?: string; statusAttribute?: string; + /** clinical attributes the adjusted Cox fit adds (at most 4; numbers as they are, categories against the commonest) */ + covariates?: string[]; +} +export interface HlaHr { hr: number; ciLow: number; ciHigh: number; p: number; n: number; events: number } +export interface HlaSplit { + /** the two groups' OS curves (exposed first), the log-rank test (stratified by study when pooled) and Cox HRs of exposed against not */ + curves: (Omit & { label: string })[]; + logrank: LogRank | null; + hr: HlaHr | null; + /** with covariates: the HR adjusted for them, on the patients with every covariate */ + adjusted: HlaHr | null; +} +export interface HlaPanel { + cohort: string; + /** cohort patients, patients with any class I allele recorded, and those typed at all three loci */ + patients: number; anyTyped: number; typed: number; + /** fully typed patients homozygous at 0, 1, 2 and 3 loci */ + homozygous: [number, number, number, number]; + /** per locus (A, B, C): patients typed there and homozygous there */ + loci: { locus: "A" | "B" | "C"; typed: number; homozygous: number }[]; + /** typed at fewer allele-name fields than the resolution asks (compared at what they have) */ + coarser: number; + /** patients with an HLA LOH call in some sample, and those with a loss */ + lohEvaluated: number; loh: number; + /** OS by homozygous at ≥ 1 locus vs none, and by HLA LOH vs none; null with why when not drawn */ + byHomozygosity: HlaSplit | null; byHomozygosityWhy?: string; + byLoh: HlaSplit | null; byLohWhy?: string; + /** patients without the endpoint, and (with covariates) without a covariate, left out of the fits */ + noOutcome: number; noCovariate: number; +} +export interface HlaResult { + resolution: 1 | 2 | "full"; endpoint: string; covariates: string[]; + panels: HlaPanel[]; } export interface LabsView { @@ -1531,8 +2336,8 @@ import type { PlotAxis, PlotData } from "./plot.ts"; import type { PointLayerSpec } from "./plot-layers.ts"; import type { ProfileInfo } from "./profile-catalog.ts"; export type { PlotAxis, PlotData }; -export type View = CompareView | SurvivalView | OncoprintView | CooccurrenceView | ClinicalView | CoexpressionView | HotspotsView | CourseView | SequenceView | PlotView | CoxView | LogisticView - | AttritionView | GenomeView | PathwaysView | DiffexprView | PairedView | ClonalityView | IncidenceView | LabsView +export type View = CompareView | SurvivalView | OncoprintView | CooccurrenceView | ClinicalView | CoexpressionView | HotspotsView | CourseView | SequenceView | PlotView | CoxView | LogisticView | ForestView | AdjustedView | ActionabilityView | DrivernegativeView | OrganotropismView | MolsubtypeView | GermlineView + | AttritionView | GenomeView | PathwaysView | DiffexprView | PairedView | ClonalityView | IncidenceView | LabsView | AcquiredView | CtdnaView | ExclusivityView | TxendpointsView | MultistateView | RmstView | PredictiveView | CutpointView | HlaView | InstabilityView | CancertypesView | OverlapView | EnrichmentView | AssaysView | CompositionView | CorrelatesView | TableView | MutationsView | RiskmodelView | TimelineView | AlterationsView; /** every view kind the document language has; a comparison against anything else is a type error */ export type ViewKind = View["kind"]; @@ -1877,6 +2682,27 @@ export interface ViewResultFields { logistic: { logistic?: LogisticResult; }; + forest: { + forest?: ForestResult; + }; + adjusted: { + adjusted?: AdjustedResult; + }; + actionability: { + actionability?: ActionabilityResult; + }; + drivernegative: { + drivernegative?: DrivernegativeResult; + }; + organotropism: { + organotropism?: OrganotropismResult; + }; + molsubtype: { + molsubtype?: MolsubtypeResult; + }; + germline: { + germline?: GermlineResult; + }; course: { course?: CourseSeries[]; /** bin width and window actually used, in months */ @@ -1937,6 +2763,18 @@ export interface ViewResultFields { paired: { paired?: PairedResult; }; + acquired: { + acquired?: AcquiredResult; + }; + ctdna: { + ctdna?: CtdnaResult; + }; + exclusivity: { + exclusivity?: ExclusivityResult; + }; + instability: { + instability?: InstabilityResult; + }; clonality: { clonality?: ClonalityResult; }; @@ -1946,6 +2784,24 @@ export interface ViewResultFields { labs: { labs?: LabsResult; }; + txendpoints: { + txendpoints?: TxendpointsResult; + }; + multistate: { + multistate?: MultistateResult; + }; + rmst: { + rmst?: RmstResult; + }; + predictive: { + predictive?: PredictiveResult; + }; + hla: { + hla?: HlaResult; + }; + cutpoint: { + cutpoint?: CutpointResult; + }; riskmodel: { riskmodel?: RiskmodelResult; }; @@ -1985,13 +2841,13 @@ export interface ViewResultFields { * nor waits for cohorts). The oncoprint has none: it carries its figure or its spec, by request. */ export interface ViewResultMain { - compare: "rows"; survival: "curves"; cox: "cox"; logistic: "logistic"; course: "course"; sequence: "sequence"; + compare: "rows"; survival: "curves"; cox: "cox"; logistic: "logistic"; forest: "forest"; adjusted: "adjusted"; actionability: "actionability"; drivernegative: "drivernegative"; organotropism: "organotropism"; molsubtype: "molsubtype"; germline: "germline"; course: "course"; sequence: "sequence"; hotspots: "hotspots"; plot: "plot"; coexpression: "coexpression"; clinical: "clinical"; cooccurrence: "pairs"; oncoprint: never; attrition: "attrition"; genome: "genome"; pathways: "pathways"; diffexpr: "diffexpr"; paired: "paired"; clonality: "clonality"; incidence: "incidence"; labs: "labs"; riskmodel: "riskmodel"; cancertypes: "cancertypes"; overlap: "overlap"; enrichment: "enrichment"; assays: "assays"; composition: "composition"; correlates: "correlates"; table: "table"; timeline: "timeline"; - mutations: "mutations"; alterations: "alterations"; + mutations: "mutations"; alterations: "alterations"; acquired: "acquired"; ctdna: "ctdna"; exclusivity: "exclusivity"; txendpoints: "txendpoints"; multistate: "multistate"; rmst: "rmst"; predictive: "predictive"; cutpoint: "cutpoint"; hla: "hla"; instability: "instability"; } /** a result that was drawn: no error, not waiting, and its main field present */ type Drawn = { error?: undefined; empty?: undefined } & ViewResultFields[K] diff --git a/lib/view-coverage.ts b/lib/view-coverage.ts index c16e6c4..453a09a 100644 --- a/lib/view-coverage.ts +++ b/lib/view-coverage.ts @@ -21,13 +21,13 @@ export interface StudyAccess { type Need = "patients" | "timeline" | "alterations" | "local"; const NEEDS: Record = { // their output is patients: columns, rows, or a model fitted on each patient - oncoprint: "patients", table: "patients", mutations: "patients", cox: "patients", logistic: "patients", riskmodel: "patients", + oncoprint: "patients", table: "patients", mutations: "patients", cox: "patients", logistic: "patients", riskmodel: "patients", predictive: "patients", cutpoint: "patients", hla: "patients", forest: "patients", adjusted: "patients", // patient views a peer may share by kind timeline: "timeline", alterations: "alterations", // aggregates every local participant computes and a remote peer does not serve yet genome: "local", pathways: "local", enrichment: "local", diffexpr: "local", paired: "local", clonality: "local", incidence: "local", labs: "local", cancertypes: "local", overlap: "local", assays: "local", composition: "local", - correlates: "local", plot: "local" + correlates: "local", plot: "local", acquired: "local", ctdna: "local", exclusivity: "local", txendpoints: "local", multistate: "local", rmst: "local", instability: "local", actionability: "local", drivernegative: "local", organotropism: "local", molsubtype: "local", germline: "local" }; /** What `kind` needs of every study it draws (null: only counts, which every participant gives). */ diff --git a/lib/view-help.ts b/lib/view-help.ts index 138d168..2f0684e 100644 --- a/lib/view-help.ts +++ b/lib/view-help.ts @@ -121,7 +121,75 @@ export const VIEW_HELP: Record = { }, riskmodel: { title: "What is this?", - text: "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw." + text: "A model that predicts survival from which genes are mutated in each patient, in the style of the OncoCast package. It is trained many times, each time on two thirds of the patients and tested on the other third, so every patient's risk score comes from a model that never saw them. Patients are then split into low, intermediate and high risk, and their survival curves are drawn. The list shows the genes the model relied on most, with a hazard ratio above 1 meaning higher risk. Tick \"validate\" to train on one half of the patients and grade the model on the other half, which it never saw, or name another group of patients under \"validate on\" to grade it there — for example patients from a different study — with the same risk-group boundaries." + }, + acquired: { + title: "What is this?", + text: "For patients who had a tumour sample tested before a treatment started and another one afterwards, shows gene by gene how many had a change only in the later sample (acquired, to the right) or only in the earlier one (lost, to the left). Genes that keep turning up only after treatment can point to how tumours resist it. It cannot say the treatment caused the change: second biopsies are usually taken when the cancer grows again, and a change can simply have been missed the first time." + }, + ctdna: { + title: "What is this?", + text: "Tumours shed DNA into the blood, and a blood test (a \"liquid biopsy\") can read it. For patients who had both a blood sample and a tumour tissue sample tested, this shows gene by gene how often a change was found in both, only in the tissue, or only in the blood — and how often the blood test caught what the tissue showed. Below, it shows how many patients had tumour DNA detected in their blood at all, and whether those patients lived shorter or longer than those without it. With the clonal-haematopoiesis filter on, changes that probably come from ageing blood cells rather than the tumour — in genes such as DNMT3A and TET2, or also found in the patient's white blood cells — are left out first, and the figure says how many were removed and what the numbers were without the filter." + }, + exclusivity: { + title: "What is this?", + text: "Asks, for each pair of genes, whether tumours carry changes in both more or less often than chance — \"mutually exclusive\" genes are rarely changed together, which can mean either change alone is enough to drive the cancer. Its sense of \"chance\" accounts for how many changes each tumour has overall: a tumour with hundreds of changes is expected to share genes with many others, so it does not make two genes look linked just because they both turn up in heavily altered tumours. Beside it is the simpler test that ignores this, to show the difference." + }, + hla: { + title: "What is this?", + text: "HLA class I molecules (HLA-A, -B and -C) show pieces of a tumour's proteins to the immune system. Everyone inherits two copies of each; a person who inherited two identical copies at a gene (homozygous) shows a narrower range of pieces, and a tumour can also lose one copy (HLA loss of heterozygosity) to hide. This shows how common each is in a cohort, and whether patients homozygous at any of the three genes, or whose tumour lost a copy, lived shorter or longer. In patients treated with immunotherapy, homozygosity has been linked to worse survival; in a cohort not limited to them the comparison describes prognosis, not response to treatment." + }, + cutpoint: { + title: "What is this?", + text: "Looks for the value of a measurement — such as tumour mutational burden — that best splits patients into a group that did better and a group that did worse. It tries every reasonable dividing line (from the lowest tenth of patients to the highest) and draws how strongly each one separates survival; the dot marks the best. Because trying many lines and keeping the best always finds something, the view also reshuffles the measurement among the patients a thousand times to see how good a 'best line' chance alone produces, and reports how surprising the real best line is once that search is accounted for. A line found this way needs checking in other patients before anyone relies on it." + }, + predictive: { + title: "What is this?", + text: "Asks, gene by gene, whether a mutation changes how patients do on a particular treatment — not just whether it marks a better or worse outlook in general. For each gene it compares patients with and without the mutation twice: among those who received the treatment (upper marker) and among those who did not (lower marker). A gene whose markers sit far apart — say, much worse with the mutation only among the treated — may predict who benefits from that treatment; the right-hand column tests that difference. Treatment here was not assigned at random, and patients had to live long enough to receive it, so a finding is a lead to test properly, not proof." + }, + rmst: { + title: "What is this?", + text: "Answers “how many months, on average, did patients in each group live within the first τ months?” — the shaded area under each survival curve up to τ. The difference between two groups is the months gained or lost in that window, which reads plainly even when the curves cross or only separate late (as with immunotherapy), where a single hazard ratio can mislead. τ is where every group still has at least ten patients followed, unless it is set. Landmark panels ask the same question again among the patients still alive and followed at 6 or 12 months, restarting the clock there. The groups were not randomised, so a difference is a clue, not proof." + }, + multistate: { + title: "What is this?", + text: "Follows each patient through three states — alive without progression, alive after their cancer was first recorded as growing, and dead — from the start of their record (in MSK-CHORD, their first tumour sequencing). The coloured bands show, month by month, what share of each group is in each state; the lines below show how fast patients move along each of the three paths (progressing, dying without progressing, dying after progressing), and the table gives each path's rate and how much faster or slower each group moves along it than the first. Splitting death into 'after progression' and 'without it' shows where a group's worse survival comes from. Progression is read from radiology reports by a model, so it is only seen in patients who were scanned; the groups were not randomised, so a difference is a clue, not proof." + }, + txendpoints: { + title: "What is this?", + text: "Starts each patient's clock on the day a treatment began — for example their first immunotherapy — and follows them to an outcome that real-world studies use in place of a trial's: when they moved on to their next treatment (time to next treatment), when they stopped this one (time on treatment), or when their cancer was recorded as growing (real-world progression-free survival). Death counts as the outcome too, and patients still followed without it are counted up to their last visit. Each group's curve shows the share not yet there at each month, with the number still followed below. The groups were not randomised to the treatment, so a gap between curves is a clue, not proof that a gene or a group changes how well the treatment works." + }, + forest: { + title: "What is this?", + text: "Asks whether one thing — a gene being mutated, a high tumour mutation burden — goes with shorter or longer survival, separately in each cancer type, and then combines those answers. Each row is one cancer type: its square is the hazard ratio (right of the dashed line at 1 means patients with the change died sooner; left means they lived longer) and its line how sure we can be. The diamonds at the bottom combine the rows into one overall estimate. When the rows disagree a lot (a high I²), the overall estimate is an average of different effects, so read the rows. This shows an association, not a cause." + }, + adjusted: { + title: "What is this?", + text: "Compares how often each commonly mutated gene is mutated in two groups of patients — say, colorectal cancers diagnosed before and after age 50 — after taking into account other things that differ between the groups and also change mutation rates, such as how many mutations a tumour carries overall. Each dot is a gene: right of centre means more often mutated in the first group, left in the second, and higher means more certain. The table shows each gene's raw difference beside the adjusted one, so you can see which differences were explained away. A difference here is a clue, not proof of a cause." + }, + instability: { + title: "What is this?", + text: "Cancer cells often gain or lose whole chromosome arms, and how much of a tumour's genome is out of balance is a measure of how unstable it is. For each group of tumours, this shows how many chromosome arms are typically gained or lost (the \"aneuploidy score\") and what share of the genome is altered, then which arms are gained (red) or lost (blue) most often — patterns that differ by cancer type, such as chromosome 20q gained and 18q lost in colorectal cancer." + }, + actionability: { + title: "What is this?", + text: "For each patient, finds the strongest evidence OncoKB lists for a drug that targets one of their tumour's genetic changes — Level 1 means an FDA-approved drug for that change, down to Level 4 for early biological evidence — and shows what share of patients reach each level, overall or per cancer type. The levels here are the best OncoKB gives the change in ANY cancer, so a change that is only actionable in another cancer still counts: read the bars as an upper bound. The table below compares, for patients sequenced twice, the level found in their primary tumour against their metastasis." + }, + germline: { + title: "What is this?", + text: "Some people are born with a faulty copy of a cancer gene such as BRCA2 in every cell. For each such gene this counts those patients (carriers) among everyone whose tumour was sequenced for it, and asks whether their tumour knocked out the remaining good copy — a second hit — by a further mutation or by deleting the gene, compared with how often the same gene is hit in patients born without a faulty copy. The commonest second hit, losing the good copy outright (loss of heterozygosity), needs data this view does not read, so the second-hit share is a minimum. Optionally the curves compare survival of carriers and non-carriers for one gene." + }, + molsubtype: { + title: "What is this?", + text: "Sorts each patient's endometrial cancer into the four molecular groups doctors now use alongside the microscope: POLE-ultramutated (a tumour with a broken DNA-copying enzyme, which does very well), mismatch-repair deficient (a tumour that cannot fix certain DNA errors, read here from its MSI test), p53-abnormal (the group that does worst), and no specific molecular profile. The groups are assigned in that order, as the WHO guideline does. Patients whose test could not tell — no MSI result, or a gene panel that missed POLE or TP53 — are counted as not classifiable rather than guessed. The bars show each group's share, overall and by the attribute you pick; the curves show survival in each group." + }, + organotropism: { + title: "What is this?", + text: "Asks, for each organ a cancer spread to — brain, liver, bone and the rest — whether patients whose tumour carries a change in a given gene spread there more or less often than patients whose tumour does not, comparing patients within the same cancer type. Red squares mean the gene's change goes with spread to that organ, blue that it goes with spreading there less; outlined squares are the ones unlikely to be chance after testing many pairs. Where the study dates each site, the curve below shows how quickly patients with and without one gene's change develop spread to one organ, counting deaths as a separate outcome. Sites are only known where scans or biopsies found them." + }, + drivernegative: { + title: "What is this?", + text: "Splits one group of patients — say, lung cancers — into those whose tumour carries one of the well-known, usually treatable cancer-driving changes (EGFR, KRAS, ALK and the rest of the list) and those whose tumour carries none, and then asks which other genes are changed more often in the second group. Those genes are candidates for what drives the tumours without a known driver. Patients whose test did not cover every gene on the list are kept apart, since we cannot tell whether they have a driver. The bar shows how big each group is; in the chart, genes to the right are more common without a known driver." }, labs: { title: "What is this?", diff --git a/lib/views/acquired.ts b/lib/views/acquired.ts new file mode 100644 index 0000000..fd72523 --- /dev/null +++ b/lib/views/acquired.ts @@ -0,0 +1,114 @@ +// Acquired view: alterations present after a treatment start and absent before it, within patients +import type { ViewResult, AcquiredView, AcquiredPanel, AcquiredGene } from "../types.ts"; +import { acquiredCountsSql, acquiredFunnelSql, type AcquiredSides } from "../federation-views.ts"; +import { benjaminiHochberg, mcnemarExact } from "../stats.ts"; +import { geneSymbols } from "../gene-tracks.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, plural, refuse } from "./shared.ts"; + +const TOP_DEFAULT = 20, TOP_MAX = 40, GENES_MAX = 30, MIN_POST_DEFAULT = 3, MIN_GAP_DEFAULT = 30; +const SAMPLE_EVENTS = ["Sample acquisition", "Sequencing", "Specimen"]; +const EVENT_NAME = /^[A-Za-z0-9_ .\/()+-]{1,120}$/; + +export async function runAcquired(view: AcquiredView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + { const e = overLimit("an acquired view", 3, vNames.length); if (e) return refuse("acquired", ctx, e.replace("at most 3", "at most 3 cohorts")); } + let genes: string[]; + try { genes = geneSymbols(view.genes || []); } catch (e) { return refuse("acquired", ctx, (e as Error).message); } + const scan = !genes.length; + { const e = overLimit("an acquired view", GENES_MAX, genes.length); if (e) return refuse("acquired", ctx, e.replace("at most " + GENES_MAX, "at most " + GENES_MAX + " genes")); } + const top = view.top == null ? TOP_DEFAULT : Math.floor(Number(view.top)); + if (!(top >= 1 && top <= TOP_MAX)) return refuse("acquired", ctx, "top must be a whole number from 1 to " + TOP_MAX + "; got " + JSON.stringify(view.top)); + const minPost = view.minPost == null ? MIN_POST_DEFAULT : Math.floor(Number(view.minPost)); + if (!(minPost >= 1 && minPost <= 1000)) return refuse("acquired", ctx, "minPost must be a whole number from 1 to 1000; got " + JSON.stringify(view.minPost)); + const minGapDays = view.minGapDays == null ? MIN_GAP_DEFAULT : Number(view.minGapDays); + if (!(Number.isInteger(minGapDays) && minGapDays >= 0 && minGapDays <= 3650)) return refuse("acquired", ctx, "minGapDays is a whole number of days from 0 to 3650"); + const preWindowDays = view.preWindowDays == null ? null : Number(view.preWindowDays); + if (preWindowDays != null && !(Number.isInteger(preWindowDays) && preWindowDays >= 0 && preWindowDays <= 36500)) + return refuse("acquired", ctx, "preWindowDays is a whole number of days from 0 to 36500, or omitted for any time before"); + const measure: "driver" | "any" = view.measure === "driver" ? "driver" : "any"; + if (measure === "driver" && !pin) + return refuse("acquired", ctx, "no OncoKB annotation pin is available here, so oncogenic alterations cannot be told apart — use measure: \"any\""); + const a = view.anchor || { eventType: "Treatment" }; + const anchor = { eventType: String(a.eventType || "").trim(), ...(a.key ? { key: String(a.key).trim(), value: String(a.value ?? "").trim() } : {}) }; + const sampleEvents = (view.sampleEvents && view.sampleEvents.length ? view.sampleEvents : SAMPLE_EVENTS).map((x) => String(x).trim()).filter(Boolean); + const sampleKey = String(view.sampleKey || "SAMPLE_ID").trim(); + for (const [k, x] of [["anchor.eventType", anchor.eventType], ["anchor.key", anchor.key], ["anchor.value", anchor.value], ["sampleKey", sampleKey], + ...sampleEvents.map((e) => ["sampleEvents", e])] as [string, string | undefined][]) + if (x != null && !EVENT_NAME.test(x)) return refuse("acquired", ctx, k + " must be a timeline name of letters, digits, spaces and . _ / ( ) + -; got " + JSON.stringify(x).slice(0, 60)); + if (!sampleEvents.length || sampleEvents.length > 6) return refuse("acquired", ctx, "sampleEvents names 1 to 6 timeline event types"); + const sides: AcquiredSides = { anchor, sampleEvents, sampleKey, preWindowDays, minGapDays }; + + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return refuse("acquired", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + const panels: AcquiredPanel[] = []; + for (const n of vNames) { + const panel: AcquiredPanel = { cohort: n, anchored: 0, dated: 0, withPre: 0, withPost: 0, patients: 0, preDays: null, postDays: null, gapDays: null, genes: [] }; + const tallies = new Map(); + const spacing: { w: number; pre: number; post: number; q1: number; median: number; q3: number }[] = []; + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + const def = doc.cohorts[n]; + const [fn, rows] = await Promise.all([ + ok[st].viewAggregate("acquired:funnel:" + n, def, (c, s, d) => acquiredFunnelSql(c, s, d, sides)), + ok[st].viewAggregate("acquired:genes:" + n, def, (c, s, d) => acquiredCountsSql(c, s, d, scan ? null : genes, sides, pin, measure)) + ]); + const f = fn[0] || {}; + panel.anchored += Number(f.anchored || 0); panel.dated += Number(f.dated || 0); + panel.withPre += Number(f.with_pre || 0); panel.withPost += Number(f.with_post || 0); + const paired = Number(f.paired || 0); + panel.patients += paired; + if (paired) spacing.push({ w: paired, pre: Number(f.pre_median), post: Number(f.post_median), q1: Number(f.gap_q1), median: Number(f.gap_median), q3: Number(f.gap_q3) }); + for (const r of rows) { + const g = String(r.gene), cur = tallies.get(g) || { pairs: 0, shared: 0, lost: 0, acquired: 0 }; + cur.pairs += Number(r.pairs); cur.shared += Number(r.both); cur.lost += Number(r.a_only); cur.acquired += Number(r.b_only); + tallies.set(g, cur); + } + })); + // one study: its own medians; several: the pair-weighted mean of each study's, said so in the caveat + const wsum = spacing.reduce((s, x) => s + x.w, 0); + const wmean = (k: "pre" | "post" | "q1" | "median" | "q3") => Math.round(spacing.reduce((s, x) => s + x.w * x[k], 0) / wsum); + if (wsum) { panel.preDays = wmean("pre"); panel.postDays = wmean("post"); panel.gapDays = { q1: wmean("q1"), median: wmean("median"), q3: wmean("q3") }; } + const all: AcquiredGene[] = (scan ? [...tallies.keys()].sort() : genes).map((g) => { + const t = tallies.get(g) || { pairs: 0, shared: 0, lost: 0, acquired: 0 }; + return { gene: g, ...t, neither: t.pairs - t.shared - t.lost - t.acquired, p: mcnemarExact(t.lost, t.acquired), q: 1 }; + }); + const tested = scan ? all.filter((g) => g.acquired + g.shared >= minPost) : all.filter((g) => g.acquired + g.lost > 0); + benjaminiHochberg(tested.map((g) => g.p)).forEach((q, i) => { tested[i].q = q; }); + if (scan) { + panel.genes = [...tested].sort((x, y) => x.p - y.p || (y.acquired - y.lost) - (x.acquired - x.lost) || + (y.acquired + y.shared) - (x.acquired + x.shared) || (x.gene < y.gene ? -1 : 1)).slice(0, top); + panel.tested = tested.length; + } else panel.genes = all; + panels.push(panel); + } + const anchoredTo = anchor.eventType + (anchor.key ? " [" + anchor.key + "=" + anchor.value + "]" : ""); + if (!panels.some((pn) => pn.patients)) { + const f = panels[0]; + return refuse("acquired", ctx, "no patient in " + (vNames.length === 1 ? "this cohort" : "these cohorts") + " has a dated sample on or before the first " + anchoredTo + + " and another at least " + minGapDays + " days after it — " + panels.map((pn) => pn.cohort + ": " + plural(pn.anchored, "patient") + " with that event, " + + plural(pn.dated, "patient") + " of them with a sample dated by " + sampleEvents.join("/") + " [" + sampleKey + "], " + + pn.withPre.toLocaleString("en-US") + " with one before, " + pn.withPost.toLocaleString("en-US") + " with one after").join("; ") + + (f.anchored ? "" : " (check anchor: the event type, key and value as the timeline records them)")); + } + const multi = ctx.vStudies.length > 1; + const what = measure === "driver" ? "an OncoKB oncogenic alteration (" + pin + ")" : "any alteration (mutation, amplification or deep deletion, structural variant)"; + return { + kind: "acquired", cohorts: vNames, + acquired: { anchoredTo, preWindowDays, minGapDays, sampleEvents, sampleKey, measure, ...(scan ? { scan: { top, minPost } } : {}), panels, + ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "Patients with a sample dated on or before their first " + anchoredTo + (preWindowDays != null ? " (at most " + preWindowDays + " days before)" : "") + + " and another at least " + minGapDays + " days after it; each side is one sample — the last before and the first after — dated by its first " + + sampleEvents.join("/") + " timeline event (attribute " + sampleKey + "). Funnel: " + panels.map((pn) => pn.cohort + " — " + pn.anchored.toLocaleString("en-US") + " started, " + + pn.withPre.toLocaleString("en-US") + " with a sample before, " + pn.withPost.toLocaleString("en-US") + " with one after, " + plural(pn.patients, "patient") + " with both" + + (pn.gapDays ? " (median " + pn.preDays + " days before and " + pn.postDays + " after; samples a median " + pn.gapDays.median + " days apart, IQR " + pn.gapDays.q1 + "–" + pn.gapDays.q3 + ")" : "")).join("; ") + ". " + + "Per gene, counting only patients whose before- AND after-sample panels both cover it: " + what + " after but not before (acquired), before but not after (lost), on both (shared). " + + "The exact McNemar test asks whether acquired and lost differ; q is Benjamini–Hochberg across the genes tested in each cohort" + + (scan ? " — every gene altered after treatment in at least " + minPost + " patients (" + panels.map((pn) => pn.cohort + ": " + plural(pn.tested || 0, "gene")).join("; ") + "), the " + top + " smallest p drawn" : "") + ". " + + "These are few patients, and a second biopsy is clinically indicated — typically taken at progression — so they are a selected group, and \"acquired\" means seen after and not before, not caused by the treatment: " + + "a subclone the first biopsy missed, a different lesion, or a lower-purity sample under-calling one side reads the same. Which treatment ran between the samples beyond the anchor is not considered." + + (multi ? " Counts are summed across " + ctx.vStudies.length + " studies" + (panels.some((pn) => pn.gapDays) ? "; the day medians are pair-weighted means of each study's" : "") + "." : "") + + (excluded.length ? " Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/actionability.ts b/lib/views/actionability.ts new file mode 100644 index 0000000..1795500 --- /dev/null +++ b/lib/views/actionability.ts @@ -0,0 +1,110 @@ +// Actionability view: each sequenced patient's highest OncoKB therapeutic level, per cohort (and per +// group of an attribute), and its concordance between a patient's primary and metastatic samples. +import type { ViewResult, ActionabilityView, ActionabilityPanel, ActionLevels } from "../types.ts"; +import { actionLevelsSql, actionConcordanceSql, actionStoreTypesSql } from "../federation-views.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; + +export const ACTION_LEVELS = ["Level 1", "Level 2", "Level 3A", "Level 3B", "Level 4", "No level"]; +export const ACTION_RESISTANCE = ["R1", "R2"]; +/** an SQL rank (1–5 sensitive, 9 none; 1–2 resistance, 9 none) to its index in ACTION_LEVELS / ACTION_RESISTANCE */ +const levelIdx = (rank: number) => rank >= 1 && rank <= 5 ? rank - 1 : 5; +const NO_VALUE = "No value recorded", MULTIPLE_LABEL = "Multiple (samples disagree)"; + +const blank = (): ActionLevels => ({ sequenced: 0, levels: [0, 0, 0, 0, 0, 0], resistance: [0, 0] }); +function add(to: ActionLevels, level: number, resistance: number, n: number) { + to.sequenced += n; to.levels[levelIdx(level)] += n; + if (resistance === 1 || resistance === 2) to.resistance[resistance - 1] += n; +} + +/** groups by size, the largest `top` kept and the rest pooled into one "Other", named. Pure. */ +export function topGroups(groups: Map, top: number): { groups: (ActionLevels & { label: string })[]; pooled?: string[] } { + const sorted = [...groups].sort((a, b) => b[1].sequenced - a[1].sequenced || a[0].localeCompare(b[0])); + const kept = sorted.slice(0, top).map(([label, g]) => ({ label, ...g })); + const rest = sorted.slice(top); + if (!rest.length) return { groups: kept }; + const other = blank(); + for (const [, g] of rest) { + other.sequenced += g.sequenced; + g.levels.forEach((n, i) => { other.levels[i] += n; }); + g.resistance.forEach((n, i) => { other.resistance[i] += n; }); + } + return { groups: [...kept, { label: "Other (" + rest.length + ")", ...other }], pooled: rest.map(([l]) => l) }; +} + +export async function runActionability(view: ActionabilityView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + const no = (e: string) => refuse("actionability", ctx, e); + if (!pin) return no("no OncoKB annotation pin is available here, so therapeutic levels cannot be read"); + { const e = overLimit("an actionability view", 4, vNames.length); if (e) return no(e.replace("at most 4", "at most 4 cohorts")); } + const groupBy = view.groupBy == null || view.groupBy === "" ? null : String(view.groupBy).trim(); + if (groupBy && !ATTR_NAME.test(groupBy)) return no("groupBy is a categorical attribute name; got " + JSON.stringify(view.groupBy).slice(0, 40)); + const top = view.top == null ? 8 : Math.floor(Number(view.top)); + if (!(top >= 1 && top <= 30)) return no("top is 1 to 30 groups"); + const vals = (x: unknown) => Array.isArray(x) ? x.map(String) : x == null ? [] : [String(x)]; + const pb = view.pairBy === null ? null : view.pairBy ?? { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }; + const pairBy = pb ? { attribute: String(pb.attribute), a: vals(pb.a), b: vals(pb.b) } : null; + if (pairBy) { + if (!ATTR_NAME.test(String(pairBy.attribute)) || !pairBy.a.length || !pairBy.b.length) return no("pairBy is {attribute, a:[values], b:[values]}, or null for no concordance"); + if (pairBy.a.some((x) => pairBy.b.map((y) => y.toLowerCase()).includes(x.toLowerCase()))) return no("pairBy's two sides share a value"); + } + const empty = vNames.filter((n) => !cohorts[n].count); + if (empty.length === vNames.length) return no("nothing to count: " + (vNames.length === 1 ? "the cohort is" : "every cohort is") + " empty"); + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return no("no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + const first = Object.values(ok)[0]; + const storeRows = await first.viewMeta("actionability:store", (s, d) => actionStoreTypesSql(s, d, pin)); + const storeTypes = storeRows.filter((r) => Number(r.n) > 0).map((r) => String(r.type)); + + const panels: ActionabilityPanel[] = []; + for (const n of vNames) { + const def = doc.cohorts[n]; + const total = blank(), groups = new Map(); + let table: number[][] | null = pairBy ? Array.from({ length: 6 }, () => [0, 0, 0, 0, 0, 0]) : null; + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + const [lv, cc] = await Promise.all([ + ok[st].viewAggregate("actionability:levels:" + n, def, (c, s, d) => actionLevelsSql(c, s, d, pin, groupBy)), + pairBy ? ok[st].viewAggregate("actionability:concordance:" + n, def, (c, s, d) => actionConcordanceSql(c, s, d, pin, pairBy)) : Promise.resolve([])]); + for (const r of lv) { + const level = Number(r.level), res = Number(r.resistance), k = Number(r.n); + add(total, level, res, k); + if (groupBy) { + const g = String(r.grp || "") === "" ? NO_VALUE : String(r.grp) === "Multiple" ? MULTIPLE_LABEL : String(r.grp); + let x = groups.get(g); if (!x) groups.set(g, x = blank()); + add(x, level, res, k); + } + } + for (const r of cc) table![levelIdx(Number(r.a))][levelIdx(Number(r.b))] += Number(r.n); + })); + const pairs = table ? table.flat().reduce((a, b) => a + b, 0) : 0; + if (table && !pairs) table = null; + panels.push({ cohort: n, patients: cohorts[n].count, ...total, + ...(groupBy ? topGroups(groups, top) : {}), + ...(table ? { concordance: { pairs, table, discordant: pairs - table.reduce((a, row, i) => a + row[i], 0) } } : {}) }); + } + if (!panels.some((p) => p.sequenced)) return no("no patient of " + (vNames.length === 1 ? "the cohort" : "these cohorts") + " has a sample sequenced for mutations"); + const share = (k: number, d: number) => d ? (100 * k / d).toFixed(1) + "%" : "–"; + const pairNote = !pairBy ? "" : panels.some((p) => p.concordance) + ? "Concordance: patients with a sequenced sample whose " + pairBy.attribute + " is " + pairBy.a.join("/") + " and one whose is " + pairBy.b.join("/") + + " (matched case-insensitively), the highest level over each side's samples compared — " + + panels.filter((p) => p.concordance).map((p) => "“" + p.cohort + "”: " + plural(p.concordance!.pairs, "patient") + ", " + share(p.concordance!.discordant, p.concordance!.pairs) + " discordant").join("; ") + + ". A level present on one side only can be a real difference between the tumours, or a gene one side's panel did not cover. " + : "No patient had a sequenced sample on both sides of " + pairBy.attribute + " (" + pairBy.a.join("/") + " against " + pairBy.b.join("/") + "), so no concordance is drawn. "; + return { + kind: "actionability", cohorts: vNames, + actionability: { pin, levels: ACTION_LEVELS, groupBy, top, pairBy, panels, storeTypes, ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(ctx.vStudies.length > 1 ? { studies: ctx.vStudies } : {}), + caveat: "Each patient's highest OncoKB therapeutic level (" + pin + ", the pinned store) over the alterations in any of their samples sequenced for mutations — called mutations and high-level copy-number calls" + + (storeTypes.includes("SV") ? ", and structural variants" : "; the store holds no level for structural variants at this pin, so a fusion is never counted") + + " — and, separately, whether any alteration carries a resistance level (R1, R2). " + + "The level is the store's highest for the alteration in ANY cancer type, not in the patient's own: an alteration actionable only elsewhere (PTEN or PIK3CA, level 1 with capivasertib in breast cancer, in any other cancer) counts here, so these shares are an upper bound on what is actionable for these patients. " + + "Denominator: patients with at least one sample sequenced for mutations — " + panels.map((p) => "“" + p.cohort + "”: " + p.sequenced.toLocaleString("en-US") + " of " + p.patients.toLocaleString("en-US")).join("; ") + + ". Panel-aware in the only way a level can be: a gene their panel did not cover is unknown, not \"no actionable alteration\" — No level means none was found on the genes sequenced. " + + (groupBy ? "Groups are " + groupBy + " per patient (" + MULTIPLE_LABEL + " when their samples disagree); the " + top + " largest are drawn and the rest pooled into Other" + + (panels.some((p) => p.pooled) ? " (" + panels.filter((p) => p.pooled).map((p) => "“" + p.cohort + "”: " + p.pooled!.length + " groups").join("; ") + ")" : "") + ". " : "") + + pairNote + + (ctx.vStudies.length > 1 ? "Counts are summed across " + ctx.vStudies.length + " studies; overlap is not deduplicated. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/adjusted.ts b/lib/views/adjusted.ts new file mode 100644 index 0000000..8f8ee10 --- /dev/null +++ b/lib/views/adjusted.ts @@ -0,0 +1,159 @@ +// Adjusted view: each gene's alteration compared between two cohorts by a logistic regression of +// altered ~ cohort + covariates on per-patient rows inside each local study, beside the raw odds +// ratio and Fisher test. Covariates are coded by ./covariates.ts, as the cox and logistic views code them. +import { logisticRegression, fisherExact, benjaminiHochberg, LOGISTIC_MAX_ITERATIONS, type LogisticRow } from "../stats.ts"; +import type { ViewResult, AdjustedView, AdjustedGene, AdjustedResult, CoxCovariate } from "../types.ts"; +import { isPatientLevel, hasViewAggregates } from "../federation.ts"; +import { adjustedRowsSql, riskmodelPanelGenesSql } from "../federation-views.ts"; +import type { AxisKind } from "../plot.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, refuse, plural, ATTR_NAME } from "./shared.ts"; +import { buildCoxColumns } from "./covariates.ts"; + +export const ADJUSTED_MAX_GENES = 200, ADJUSTED_MAX_COVARIATES = 6; +/** a gene's cohort term is reported only when its log-OR's standard error is below this (larger: separation) */ +const ADJUSTED_MAX_SE = 5; + +/** one patient as the fits read them: which cohort (1 = the first), the genes their panels cover (null: every gene), mutated genes, covariates */ +export interface AdjustedPatient { group: 0 | 1; covers: Set | null; genes: Set; values: (number | string)[] } + +/** numbers when every value parses as one, else categories */ +export function covariateKinds(patients: { values: (number | string)[] }[], n: number): AxisKind[] { + return Array.from({ length: n }, (_, k) => patients.every((r) => r.values[k] !== "" && Number.isFinite(Number(r.values[k]))) ? "num" : "cat"); +} + +/** + * Per gene: the patients profiled for it, the raw 2×2 (Fisher), and the logistic fit of altered on + * the cohort indicator and the covariates (coded within the gene's patients); genes whose fit does + * not converge or separates are returned in `failed`, never dropped. q is BH over the genes fit. + * Pure, so it is testable without a database. + */ +export function adjustedFit(patients: AdjustedPatient[], genes: string[], covariates: string[], kinds: AxisKind[]): + { rows: AdjustedGene[]; failed: { gene: string; reason: string }[] } { + const axes: CoxCovariate[] = covariates.map((a) => ({ source: "clinical", attribute: a })); + const rows: AdjustedGene[] = [], failed: { gene: string; reason: string }[] = []; + for (const gene of genes) { + const pts = patients.filter((r) => !r.covers || r.covers.has(gene)); + const altered: [number, number] = [0, 0], profiled: [number, number] = [0, 0]; + for (const r of pts) { const i = r.group ? 0 : 1; profiled[i]++; if (r.genes.has(gene)) altered[i]++; } + const [a, c] = altered, b = profiled[0] - a, d = profiled[1] - c; + if (!profiled[0] || !profiled[1]) { failed.push({ gene, reason: "not profiled in one of the cohorts" }); continue; } + const rawOr = b * c > 0 && a * d > 0 ? (a * d) / (b * c) : null; + const fisherP = fisherExact(a, b, c, d); + // a numeric covariate that does not vary has nothing to adjust; a category in which no patient (or + // every patient) is mutated separates, so for this gene it joins the reference level + let merged = false; + const cols = buildCoxColumns(axes, kinds, pts).filter((col) => { + if (kinds[col.axisIndex] === "num") return new Set(pts.map((r) => Number(r.values[col.axisIndex]))).size > 1; + const at = pts.filter((r) => String(r.values[col.axisIndex]) === col.level), hit = at.filter((r) => r.genes.has(gene)).length; + const keep = hit > 0 && hit < at.length; + if (!keep) merged = true; + return keep; + }); + const input: LogisticRow[] = pts.map((r) => ({ y: r.genes.has(gene) ? 1 : 0, + x: [r.group, ...cols.map((col) => kinds[col.axisIndex] === "num" ? Number(r.values[col.axisIndex]) : String(r.values[col.axisIndex]) === col.level ? 1 : 0)] })); + let fit; + try { fit = logisticRegression(input); } + catch { fit = null; } + if (!fit || fit.iterations >= LOGISTIC_MAX_ITERATIONS || !(fit.se[0] > 0 && fit.se[0] < ADJUSTED_MAX_SE) || !Number.isFinite(fit.beta[0])) { + failed.push({ gene, reason: rawOr == null ? "an empty cell in the 2×2" : "the fit did not converge: a covariate nearly decides the alteration" }); + continue; + } + rows.push({ gene, altered, profiled, rawOr, fisherP, or: fit.or[0], ciLow: fit.ciLow[0], ciHigh: fit.ciHigh[0], p: fit.p[0], q: 1, ...(merged ? { merged } : {}) }); + } + benjaminiHochberg(rows.map((g) => g.p)).forEach((q, i) => { rows[i].q = q; }); + rows.sort((x, y) => x.p - y.p || x.gene.localeCompare(y.gene)); + return { rows, failed }; +} + +export async function runAdjusted(view: AdjustedView, ctx: ViewRun): Promise { + const { doc, cohorts, resolvers, vNames, vStudies, pin } = ctx; + const no = (e: string) => refuse("adjusted", ctx, e); + if (vNames.length !== 2) return no("an adjusted view compares exactly two cohorts; this one draws " + vNames.length); + const covariates = (Array.isArray(view.covariates) ? view.covariates : []).map((c) => String(c).trim()); + { const e = overLimit("an adjusted view", ADJUSTED_MAX_COVARIATES, covariates.length); if (e) return no(e.replace("at most " + ADJUSTED_MAX_COVARIATES, "at most " + ADJUSTED_MAX_COVARIATES + " covariates")); } + const bad = covariates.find((c) => !ATTR_NAME.test(c)); + if (bad != null) return no("covariate '" + bad.slice(0, 40) + "' is not an attribute name"); + if (new Set(covariates).size !== covariates.length) return no("a covariate is listed twice"); + const measure: "any" | "driver" = view.measure === "driver" ? "driver" : "any"; + if (measure === "driver" && !pin) return no("no OncoKB annotation pin is available here, so drivers cannot be called — use measure: \"any\""); + const minShare = view.minShare == null ? 0.05 : Number(view.minShare); + if (!(minShare > 0 && minShare <= 1)) return no("minShare is a share of patients, above 0 and at most 1"); + const minAltered = view.minAltered == null ? 10 : Math.floor(Number(view.minAltered)); + if (!(minAltered >= 1 && minAltered <= 100000)) return no("minAltered is 1 to 100000"); + const labelN = view.label == null ? 12 : Math.floor(Number(view.label)); + if (!(labelN >= 0 && labelN <= 40)) return no("label is 0 to 40 genes"); + const empty = vNames.filter((n) => !cohorts[n].count); + if (empty.length) return no("nothing to compare: cohort " + empty.map((n) => "“" + n + "”").join(" and ") + (empty.length === 1 ? " is" : " are") + " empty"); + + const patients: AdjustedPatient[] = []; + const excludedRemote: { study: string; patients: number }[] = []; + const withPanel = [0, 0], used = [0, 0]; + for (const [gi, n] of vNames.entries()) for (const st of cohorts[n].studies) { + const res = resolvers[st]; + if (!isPatientLevel(res) || !hasViewAggregates(res)) { excludedRemote.push({ study: st, patients: cohorts[n].perStudy[st]?.count || 0 }); continue; } + const [rows, panelRows] = await Promise.all([ + res.viewRows("adjusted:rows:" + n, doc.cohorts[n], (c, s, d) => adjustedRowsSql(c, s, d, { covariates, driverPin: measure === "driver" ? pin : null })), + res.viewMeta("adjusted:panels", (s, d) => riskmodelPanelGenesSql(s, d))]); + const panelGenes = new Map(panelRows.map((x) => [String(x.panel), (x.genes as unknown as string[]) || []] as const)); + const coverCache = new Map | null>(); + const covers = (panels: string[]): Set | null => { + const key = [...panels].sort().join("|"); + if (!coverCache.has(key)) coverCache.set(key, panels.includes("WES") ? null : new Set(panels.flatMap((p) => panelGenes.get(p) || []))); + return coverCache.get(key)!; + }; + for (const r of rows) { + withPanel[gi]++; + const values = covariates.map((_, k) => String(r["c" + k] ?? "").trim()); + if (values.some((x) => x === "")) continue; + used[gi]++; + patients.push({ group: gi === 0 ? 1 : 0, covers: covers((r.panels as unknown as string[]) || []), genes: new Set((r.genes as unknown as string[]) || []), values }); + } + } + if (!patients.length) return no(excludedRemote.length && !withPanel[0] && !withPanel[1] + ? "no patient-level participant could answer: " + excludedRemote.map((e) => e.study).join(", ") + " answer with counts only, and a per-patient regression needs patient rows" + : "no patient had a mutation panel" + (covariates.length ? " and every covariate (" + covariates.join(", ") + ") on record" : "")); + if (!used[0] || !used[1]) return no("cohort “" + vNames[used[0] ? 1 : 0] + "” has no patient with a mutation panel" + (covariates.length ? " and every covariate on record" : "")); + const kinds = covariateKinds(patients, covariates.length); + const cats = kinds.flatMap((k, i) => k === "cat" ? [{ a: covariates[i], n: new Set(patients.map((r) => r.values[i])).size }] : []).filter((x) => x.n > 30); + if (cats.length) return no(cats.map((x) => x.a + " takes " + x.n + " values").join(", ") + "; a categorical covariate takes at most 30"); + + // genes mutated in at least minShare of the patients profiled for them, both cohorts together + const altered = new Map(); + for (const r of patients) for (const g of r.genes) if (!r.covers || r.covers.has(g)) altered.set(g, (altered.get(g) || 0) + 1); + const byCover = new Map | null, number>(); + for (const r of patients) byCover.set(r.covers, (byCover.get(r.covers) || 0) + 1); + const profiledFor = (g: string) => { let n = 0; for (const [cv, k] of byCover) if (!cv || cv.has(g)) n += k; return n; }; + const genes = [...altered].filter(([g, a]) => a >= minAltered && a / profiledFor(g) >= minShare).map(([g]) => g).sort(); + if (!genes.length) return no("no gene is mutated in at least " + (100 * minShare).toFixed(1) + "% (and " + minAltered + ") of the patients profiled for it; lower minShare"); + { const e = overLimit("an adjusted view", ADJUSTED_MAX_GENES, genes.length); if (e) return no(e.replace("at most " + ADJUSTED_MAX_GENES, "at most " + ADJUSTED_MAX_GENES + " genes").replace("remove " + (genes.length - ADJUSTED_MAX_GENES), "raise minShare to test fewer") + " (" + genes.length + " genes are mutated in at least " + (100 * minShare).toFixed(1) + "% of patients)"); } + + const { rows, failed } = adjustedFit(patients, genes, covariates, kinds); + if (!rows.length) return no("no gene could be fit: " + failed.slice(0, 10).map((f) => f.gene + " (" + f.reason + ")").join("; ")); + const significant = rows.filter((g) => g.q < 0.05).length; + const labels = rows.filter((g) => g.q < 0.05).slice(0, labelN).map((g) => g.gene); + const result: AdjustedResult = { groups: [vNames[0], vNames[1]], covariates, measure, minShare, minAltered, + patients: vNames.map((c, i) => ({ cohort: c, withPanel: withPanel[i], used: used[i] })), + rows, failed, significant, labels, ...(excludedRemote.length ? { excludedRemote } : {}) }; + const mergedN = rows.filter((g) => g.merged).length; + const flips = rows.filter((g) => g.fisherP < 0.05 && g.p >= 0.05).length; + const multi = vStudies.length > 1; + return { + kind: "adjusted", cohorts: vNames, adjusted: result, + participation: ctx.participationFor(vStudies, true), + ...(multi ? { studies: vStudies } : {}), + caveat: "Per gene, a logistic regression of mutated (yes/no) on cohort (“" + vNames[0] + "” = 1 against “" + vNames[1] + "”)" + + (covariates.length ? " and " + covariates.join(", ") + " (" + covariates.map((c, i) => c + (kinds[i] === "num" ? " as a number" : " as categories against its commonest value")).join(", ") + "; a sample attribute is the patient's first sample's)" : ", with no covariates — so the adjusted odds ratio is the raw one, from a model") + + "; the odds ratio is the cohort term's, Wald 95% CI, and q is Benjamini–Hochberg over the " + rows.length + " genes fit. " + + "Patients, not samples: a patient is mutated in a gene when any of their samples carries a called " + (measure === "driver" ? "OncoKB driver mutation (" + pin + ")" : "mutation") + " in it, and is in a gene's model only when one of their mutation panels covers it (or whole-exome) — panel-aware, so a patient not profiled for a gene is left out of that gene, never counted as wild type. " + + "Tested: the " + plural(genes.length, "gene") + " mutated in at least " + (100 * minShare).toFixed(1) + "% (and " + minAltered + ") of the patients profiled for them, both cohorts together (at most " + ADJUSTED_MAX_GENES + "). " + + vNames.map((c, i) => "“" + c + "”: " + plural(withPanel[i], "patient") + " with a mutation panel" + (covariates.length ? ", " + used[i].toLocaleString("en-US") + " with every covariate" : "")).join("; ") + ". " + + "Beside each adjusted odds ratio is the raw one with its two-sided Fisher p, on the same patients" + (flips ? "; " + plural(flips, "gene") + " significant raw (Fisher p < 0.05) are not after adjustment (adjusted p ≥ 0.05)" : "") + ". " + + (mergedN ? "For " + plural(mergedN, "gene") + ", a covariate category in which no patient (or every patient) was mutated was merged into its reference level, since it would separate. " : "") + + (failed.length ? "Not fit, listed rather than dropped: " + failed.slice(0, 15).map((f) => f.gene + " (" + f.reason + ")").join("; ") + (failed.length > 15 ? "; and " + (failed.length - 15) + " more" : "") + ". " : "") + + "Adjustment removes only what the covariates measure: a hypermutated tumour raises every gene's odds at once, which TMB or MSI status as a covariate absorbs and nothing else does. " + + "Cohorts are expected not to share patients; a patient in both would count on both sides. " + + (multi ? "Pooled across " + vStudies.length + " studies without a study term; vocabularies are not harmonized. " : "") + + (excludedRemote.length ? "Left out: " + excludedRemote.map((e) => e.study + " (" + e.patients + " patients, aggregate-only)").join(", ") + "." : "") + }; +} diff --git a/lib/views/cox.ts b/lib/views/cox.ts index 1c95c7a..9da7f7d 100644 --- a/lib/views/cox.ts +++ b/lib/views/cox.ts @@ -6,7 +6,7 @@ import { isPatientLevel, type EndpointColumns } from "../federation.ts"; import { validateAxis, type AxisKind } from "../plot.ts"; import { coxMethod, type CoxTrace } from "../method.ts"; import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { overLimit, studyCatalogs } from "./shared.ts"; +import { overLimit, studyCatalogs, ATTR_NAME } from "./shared.ts"; import { buildCoxColumns, notProfiledNote } from "./covariates.ts"; import { resolveProfileAxis } from "../plot-profile.ts"; @@ -25,9 +25,9 @@ export async function runCox(view: DocView, c: ViewRun): Promise/): got '" + monthsAttr.slice(0, 40) + "'/'" + statusAttr.slice(0, 40) + "'" }); } endpointColumns = { months: monthsAttr, status: statusAttr }; diff --git a/lib/views/ctdna.ts b/lib/views/ctdna.ts new file mode 100644 index 0000000..65bfbcc --- /dev/null +++ b/lib/views/ctdna.ts @@ -0,0 +1,214 @@ +// ctDNA view: a patient's plasma (cfDNA) samples against their tissue, gene by gene, and survival by ctDNA detected +import type { ViewResult, CtdnaView, CtdnaPanel, CtdnaGene, CtdnaChPanel, LogRank } from "../types.ts"; + +type CtdnaChGene = CtdnaChPanel["genes"][number]; +import { ctdnaCountsSql, ctdnaFunnelSql, ctdnaSurvivalSql, ctdnaChRemovedSql, sampleAttributeValuesSql, type CtdnaSides } from "../federation-views.ts"; +import { kaplanMeier, logRank, wilsonInterval, type SurvRecord } from "../stats.ts"; +import { geneSymbols } from "../gene-tracks.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, plural, refuse, ATTR_NAME } from "./shared.ts"; + +const TOP_DEFAULT = 15, TOP_MAX = 30, GENES_MAX = 30; +const SAMPLE_EVENTS = ["Sample acquisition", "Sequencing", "Specimen"]; +const NAME = /^[A-Za-z0-9_ .\/()+-]{1,120}$/; +/** fewer patients than this in either group and the survival panel is not drawn */ +const KM_MIN_GROUP = 5; + +const pct = (x: number) => (100 * x).toFixed(1) + "%"; + +/** + * Genes whose plasma mutation calls the CH filter removes by default: the genes most often mutated + * in clonal haematopoiesis among the white-cell-matched cfDNA variants of Razavi et al. (Nat Med + * 2019) and the CH of Bolton et al. (Nat Genet 2020). TP53, ATM and CHEK2 are CH genes too, but + * also tumour drivers whose plasma calls are often the tumour's own, so they are left in; the + * matched-normal rule catches their CH calls where a study loads white-cell read counts. + */ +export const CH_GENES = ["DNMT3A", "TET2", "ASXL1", "PPM1D", "JAK2", "SF3B1", "SRSF2", "U2AF1", "CBL", "GNB1", "GNAS", "IDH2"]; +/** the matched-normal rule: at least this many alternate reads, at an allele fraction at least this share of the plasma's (tumour DNA reaching the white-cell sample sits far below it) */ +const CH_NORMAL_MIN_ALT = 2, CH_NORMAL_MIN_RATIO = 0.3; + +export async function runCtdna(view: CtdnaView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + { const e = overLimit("a ctdna view", 3, vNames.length); if (e) return refuse("ctdna", ctx, e.replace("at most 3", "at most 3 cohorts")); } + let genes: string[]; + try { genes = geneSymbols(view.genes || []); } catch (e) { return refuse("ctdna", ctx, (e as Error).message); } + { const e = overLimit("a ctdna view", GENES_MAX, genes.length); if (e) return refuse("ctdna", ctx, e.replace("at most " + GENES_MAX, "at most " + GENES_MAX + " genes")); } + const pick = !genes.length; + const top = view.top == null ? TOP_DEFAULT : Math.floor(Number(view.top)); + if (!(top >= 1 && top <= TOP_MAX)) return refuse("ctdna", ctx, "top must be a whole number from 1 to " + TOP_MAX + "; got " + JSON.stringify(view.top)); + const measure: "driver" | "any" = view.measure === "driver" ? "driver" : "any"; + if (measure === "driver" && !pin) + return refuse("ctdna", ctx, "no OncoKB annotation pin is available here, so oncogenic alterations cannot be told apart — use measure: \"any\""); + const list = (x: string[] | undefined, d: string[]) => (x && x.length ? x : d).map((v) => String(v).trim()).filter(Boolean); + const attribute = String(view.attribute || "SAMPLE_CLASS").trim(); + if (!ATTR_NAME.test(attribute)) return refuse("ctdna", ctx, "attribute must be a sample attribute name; got " + JSON.stringify(attribute).slice(0, 40)); + const plasma = list(view.plasma, ["cfDNA", "ctDNA"]), tissue = list(view.tissue, ["Tumor"]); + const plasmaPanels = list(view.plasmaPanels, []); + const both = plasma.filter((v) => tissue.some((t) => t.toLowerCase() === v.toLowerCase())); + if (both.length) return refuse("ctdna", ctx, "a value cannot be both plasma and tissue: " + both.join(", ")); + if (plasma.length > 10 || tissue.length > 10 || plasmaPanels.length > 10) return refuse("ctdna", ctx, "plasma, tissue and plasmaPanels take at most 10 values each"); + const withinDays = view.withinDays == null ? null : Number(view.withinDays); + if (withinDays != null && !(Number.isInteger(withinDays) && withinDays >= 0 && withinDays <= 3650)) return refuse("ctdna", ctx, "withinDays is a whole number of days from 0 to 3650"); + const sampleEvents = list(view.sampleEvents, SAMPLE_EVENTS), sampleKey = String(view.sampleKey || "SAMPLE_ID").trim(); + for (const x of [...plasma, ...tissue, ...plasmaPanels, ...sampleEvents, sampleKey]) + if (!NAME.test(x)) return refuse("ctdna", ctx, "a value, panel or event name has characters it cannot: " + JSON.stringify(x).slice(0, 60)); + const sides: CtdnaSides = { attribute, plasma, tissue, plasmaPanels, withinDays, sampleEvents, sampleKey }; + let ch: NonNullable | null = null; + if (view.chFilter != null && view.chFilter !== false) { + const f = view.chFilter === true ? {} : view.chFilter; + if (typeof f !== "object" || Array.isArray(f)) return refuse("ctdna", ctx, "chFilter is true, or {genes?, normal?, normalMinAlt?, normalMinRatio?}"); + let chGenes: string[]; + try { chGenes = f.genes == null ? CH_GENES : geneSymbols(f.genes); } catch (e) { return refuse("ctdna", ctx, "chFilter.genes: " + (e as Error).message); } + if (chGenes.length > 40) return refuse("ctdna", ctx, "chFilter.genes takes at most 40 genes"); + const minAlt = f.normalMinAlt == null ? CH_NORMAL_MIN_ALT : Number(f.normalMinAlt); + if (!(Number.isInteger(minAlt) && minAlt >= 1 && minAlt <= 1000)) return refuse("ctdna", ctx, "chFilter.normalMinAlt is a whole number of reads from 1 to 1000"); + const normalMinAlt = f.normal === false ? null : minAlt; + const normalMinRatio = f.normalMinRatio == null ? CH_NORMAL_MIN_RATIO : Number(f.normalMinRatio); + if (!(Number.isFinite(normalMinRatio) && normalMinRatio >= 0 && normalMinRatio <= 10)) return refuse("ctdna", ctx, "chFilter.normalMinRatio is a number from 0 to 10"); + if (!chGenes.length && normalMinAlt == null) return refuse("ctdna", ctx, "chFilter with no genes and normal: false removes nothing — give it genes, or the matched-normal rule"); + ch = { genes: chGenes, normalMinAlt, normalMinRatio }; + } + + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return refuse("ctdna", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + /** each cohort's panel from the sides `sd`; `chosen` fixes the genes in place of the view's own choice */ + const measurePanels = async (sd: CtdnaSides, tag: string, chosen: string[] | null): Promise => { + const panels: CtdnaPanel[] = []; + for (const n of vNames) { + const panel: CtdnaPanel = { cohort: n, plasmaPatients: 0, tissuePatients: 0, patients: 0, plasmaSamples: 0, tissueSamples: 0, + detected: 0, detectedLow: null, detectedHigh: null, genes: [], survival: null }; + const undated = { plasmaPatients: 0, tissuePatients: 0, datedSamples: 0 }; + const tallies = new Map(); + const surv: { study: string; det: SurvRecord[]; not: SurvRecord[] }[] = []; + let missingOs = 0; + const chc = { calls: 0, withNormal: 0, byGene: 0, byNormal: 0, removed: 0, patients: 0 }; + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + const def = doc.cohorts[n]; + const [fn, rows, os] = await Promise.all([ + ok[st].viewAggregate(tag + "funnel:" + n, def, (c, s, d) => ctdnaFunnelSql(c, s, d, sd, pin, measure)), + ok[st].viewAggregate(tag + "genes:" + n, def, (c, s, d) => ctdnaCountsSql(c, s, d, chosen || (pick ? null : genes), sd, pin, measure)), + ok[st].viewAggregate(tag + "survival:" + n, def, (c, s, d) => ctdnaSurvivalSql(c, s, d, sd, pin, measure)) + ]); + const f = fn[0] || {}; + panel.plasmaPatients += Number(f.plasma || 0); panel.tissuePatients += Number(f.tissue || 0); panel.patients += Number(f.both || 0); + panel.plasmaSamples += Number(f.plasma_samples || 0); panel.tissueSamples += Number(f.tissue_samples || 0); + panel.detected += Number(f.detected || 0); + undated.plasmaPatients += Number(f.plasma_all || 0); undated.tissuePatients += Number(f.tissue_all || 0); undated.datedSamples += Number(f.dated_samples || 0); + if (sd.ch) { + chc.calls += Number(f.ch_calls || 0); chc.withNormal += Number(f.ch_with_normal || 0); chc.byGene += Number(f.ch_by_gene || 0); + chc.byNormal += Number(f.ch_by_normal || 0); chc.removed += Number(f.ch_removed || 0); chc.patients += Number(f.ch_patients || 0); + } + for (const r of rows) { + const g = String(r.gene), cur = tallies.get(g) || { pairs: 0, both: 0, tissueOnly: 0, plasmaOnly: 0 }; + cur.pairs += Number(r.pairs); cur.both += Number(r.both); cur.tissueOnly += Number(r.a_only); cur.plasmaOnly += Number(r.b_only); + tallies.set(g, cur); + } + const s = { study: st, det: [] as SurvRecord[], not: [] as SurvRecord[] }; + let withOs = 0; + for (const r of os) { + const k = Number(r.n), rec = { t: Number(r.t), event: Number(r.dead) ? 1 : 0 }; + withOs += k; + for (let i = 0; i < k; i++) (Number(r.detected) ? s.det : s.not).push(rec); + } + missingOs += Math.max(0, Number(f.plasma || 0) - withOs); + if (withOs) surv.push(s); + })); + if (withinDays != null) panel.undated = undated; + const ci = wilsonInterval(panel.detected, panel.plasmaPatients); + if (ci) { panel.detectedLow = ci.low; panel.detectedHigh = ci.high; } + const all: CtdnaGene[] = (chosen || (pick ? [...tallies.keys()] : genes)).map((g) => { + const t = tallies.get(g) || { pairs: 0, both: 0, tissueOnly: 0, plasmaOnly: 0 }; + const inTissue = t.both + t.tissueOnly, w = wilsonInterval(t.both, inTissue), neither = t.pairs - t.both - t.tissueOnly - t.plasmaOnly; + return { gene: g, ...t, neither, ppa: inTissue ? t.both / inTissue : null, ppaLow: w ? w.low : null, ppaHigh: w ? w.high : null, + concordance: t.pairs ? (t.both + neither) / t.pairs : null }; + }); + const altered = (g: CtdnaGene) => g.both + g.tissueOnly + g.plasmaOnly; + panel.genes = pick && !chosen ? all.filter((g) => altered(g) > 0).sort((x, y) => altered(y) - altered(x) || (x.gene < y.gene ? -1 : 1)).slice(0, top) : all; + const det = surv.flatMap((s) => s.det), not = surv.flatMap((s) => s.not); + if (!surv.length) panel.survivalWhy = "no plasma patient has OS_MONTHS"; + else if (det.length < KM_MIN_GROUP || not.length < KM_MIN_GROUP) panel.survivalWhy = "fewer than " + KM_MIN_GROUP + " patients with OS in a group (" + det.length + " detected, " + not.length + " not)"; + else { + const lr: LogRank | null = det.length && not.length ? logRank(surv.map((s) => ({ a: s.det, b: s.not }))) : null; + panel.survival = { curves: [{ label: "ctDNA detected", ...kaplanMeier(det) }, { label: "not detected", ...kaplanMeier(not) }], logrank: lr, missing: missingOs }; + } + if (sd.ch) panel.ch = { ...chc, genes: [], before: { detected: 0, detectedLow: null, detectedHigh: null, genes: [], survival: null } }; + panels.push(panel); + } + return panels; + }; + const panels = await measurePanels(ch ? { ...sides, ch } : sides, "ctdna:", null); + if (ch) { + const chSides = { ...sides, ch }; + const before = await measurePanels(sides, "ctdna:unfiltered:", [...new Set(panels.flatMap((pn) => pn.genes.map((g) => g.gene)))]); + for (const [i, pn] of panels.entries()) { + const b = before[i], c = pn.ch!; + const removed = new Map(); + await Promise.all(cohorts[pn.cohort].studies.filter((st) => ok[st]).map(async (st) => { + for (const r of await ok[st].viewAggregate("ctdna:ch-removed:" + pn.cohort, doc.cohorts[pn.cohort], (cs, s, d) => ctdnaChRemovedSql(cs, s, d, chSides))) { + const g = String(r.gene), cur = removed.get(g) || { gene: g, calls: 0, patients: 0, byGene: 0, byNormal: 0 }; + cur.calls += Number(r.calls); cur.patients += Number(r.patients); cur.byGene += Number(r.gene_calls); cur.byNormal += Number(r.normal_calls); + removed.set(g, cur); + } + })); + c.genes = [...removed.values()].sort((x, y) => y.calls - x.calls || (x.gene < y.gene ? -1 : 1)); + c.before = { + detected: b.detected, detectedLow: b.detectedLow, detectedHigh: b.detectedHigh, + genes: b.genes.map((g) => ({ gene: g.gene, both: g.both, plasmaOnly: g.plasmaOnly, ppa: g.ppa })), + survival: b.survival ? { curves: b.survival.curves.map((cv) => ({ label: cv.label, n: cv.n, events: cv.events, median: cv.median })), p: b.survival.logrank ? b.survival.logrank.p : null } : null, + }; + } + } + if (!panels.some((pn) => pn.patients)) { + const st = Object.keys(ok)[0]; + const vals = await ok[st].viewAggregate("ctdna:values", doc.cohorts[vNames[0]], (c, s, d) => sampleAttributeValuesSql(c, s, d, attribute)); + return refuse("ctdna", ctx, "no patient in " + (vNames.length === 1 ? "this cohort" : "these cohorts") + " has both a plasma sample (" + attribute + " = " + plasma.join("/") + + (plasmaPanels.length ? ", or panel " + plasmaPanels.join("/") : "") + ") and a tissue sample (" + tissue.join("/") + ")" + + (withinDays != null ? " dated within " + withinDays + " days of each other — " + panels.map((pn) => pn.cohort + ": " + plural(pn.undated?.plasmaPatients || 0, "patient") + + " with plasma, " + plural(pn.undated?.tissuePatients || 0, "patient") + " with tissue, " + plural(pn.undated?.datedSamples || 0, "sample") + " dated by " + sampleEvents.join("/")).join("; ") : "") + + (vals.length ? " — " + attribute + " here takes: " + vals.map((v) => String(v.value) + " (" + Number(v.samples).toLocaleString("en-US") + ")").join(", ") + : " — " + st + " has no sample attribute " + attribute)); + } + const multi = ctx.vStudies.length > 1; + const what = measure === "driver" ? "an OncoKB oncogenic alteration (" + pin + ")" : "any alteration (mutation, amplification or deep deletion, structural variant)"; + const plasmaText = attribute + " = " + plasma.join("/") + (plasmaPanels.length ? " or a sample on " + plasmaPanels.join("/") : ""); + return { + kind: "ctdna", cohorts: vNames, + ctdna: { attribute, plasma, tissue, plasmaPanels, withinDays, measure, ...(pick ? { top } : {}), panels, ...(excluded.length ? { excluded } : {}), ...(ch ? { chFilter: ch } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "Plasma is " + plasmaText + ", tissue " + attribute + " = " + tissue.join("/") + ". " + + panels.map((pn) => pn.cohort + ": " + plural(pn.plasmaPatients, "patient") + " with plasma, " + plural(pn.tissuePatients, "patient") + " with tissue, " + + plural(pn.patients, "patient") + " with both (" + pn.plasmaSamples.toLocaleString("en-US") + " plasma and " + pn.tissueSamples.toLocaleString("en-US") + " tissue samples); ctDNA detected in " + + pn.detected.toLocaleString("en-US") + " of the plasma patients (" + (pn.plasmaPatients ? pct(pn.detected / pn.plasmaPatients) : "—") + ")").join("; ") + ". " + + (withinDays != null ? "Only samples the timeline dates (first " + sampleEvents.join("/") + " event naming them in " + sampleKey + ") within " + withinDays + " days of a sample of the other kind are used: " + + panels.map((pn) => pn.cohort + " — " + plural(pn.undated?.datedSamples || 0, "classed sample") + " dated, of " + plural(pn.undated?.plasmaPatients || 0, "plasma patient") + + " and " + plural(pn.undated?.tissuePatients || 0, "tissue patient") + " before the rule").join("; ") + ". " + : "Plasma and tissue are paired by patient whenever they were drawn, not by date — the tumour may have changed between them. ") + + "Per gene, counting only patients whose plasma AND tissue panels both cover it, " + what + " in both, tissue only, plasma only, or neither; a patient with several samples of a kind is altered there if any is. " + + "Positive percent agreement is both ÷ altered in tissue (tissue the reference), with a 95% Wilson interval; overall concordance is (both + neither) ÷ pairs. " + + (pick ? "The genes are the " + top + " most altered in the paired patients, not chosen in advance. " : "") + + (ch ? chCaveat(ch, panels) + : "Plasma-only calls include clonal haematopoiesis (TP53, DNMT3A, TET2, PPM1D…) when the assay does not subtract white-cell DNA, and tissue-only calls a tumour shedding too little DNA — both are biology of the assay, not error. ") + + "ctDNA detected means " + what + " called in some plasma sample, over every gene the plasma panel covers. " + + "Survival is OS_MONTHS / OS_STATUS as each study records them, from its own origin, which need not be the plasma draw; when the draw comes after that origin the comparison carries immortal-time bias. " + + (multi ? "Counts are summed across " + ctx.vStudies.length + " studies; the log-rank test is stratified by study. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} + +/** what the CH filter did, cohort by cohort, with the headline numbers before it */ +function chCaveat(ch: NonNullable, panels: CtdnaPanel[]): string { + const rule = (ch.genes.length ? "in " + ch.genes.join(", ") : "") + (ch.genes.length && ch.normalMinAlt != null ? ", or " : "") + + (ch.normalMinAlt != null ? "seen in the matched normal (white-cell) sample — at least " + ch.normalMinAlt + " alternate reads there, at an allele fraction at least " + ch.normalMinRatio + " × the plasma's" : ""); + return "Clonal-haematopoiesis filter: plasma mutation calls " + rule + " are left out of every count below (copy-number and structural calls are kept; tissue calls are never filtered). " + + panels.map((pn) => { + const c = pn.ch!; + return pn.cohort + " — " + plural(c.removed, "plasma mutation call") + " of " + c.calls.toLocaleString("en-US") + " removed (" + c.byGene.toLocaleString("en-US") + " by gene, " + + c.byNormal.toLocaleString("en-US") + " by the normal), in " + plural(c.patients, "patient") + "; " + + (ch.normalMinAlt != null ? (c.withNormal < c.calls ? (c.calls - c.withNormal).toLocaleString("en-US") + " calls have no matched-normal counts loaded, so only the gene rule could apply to them; " : "") : "") + + "ctDNA detected " + pn.detected.toLocaleString("en-US") + " (" + (pn.plasmaPatients ? pct(pn.detected / pn.plasmaPatients) : "—") + ") against " + c.before.detected.toLocaleString("en-US") + + " (" + (pn.plasmaPatients ? pct(c.before.detected / pn.plasmaPatients) : "—") + ") unfiltered"; + }).join("; ") + ". " + + "The gene rule also removes a tumour's own calls in those genes (GNAS, IDH2, SF3B1 and others can be tumour drivers); the allele-fraction ratio keeps calls whose normal reads are tumour DNA in the white-cell sample (far below the plasma's fraction), and a CH clone at very low fraction in blood can still pass; TP53, ATM and CHEK2 are not on the default list because they are tumour drivers as often as CH genes (Razavi et al. 2019; Bolton et al. 2020). " + + "Tissue-only calls remain those of a tumour shedding too little DNA. "; +} diff --git a/lib/views/cutpoint.ts b/lib/views/cutpoint.ts new file mode 100644 index 0000000..0a28165 --- /dev/null +++ b/lib/views/cutpoint.ts @@ -0,0 +1,172 @@ +// Cut-point view: the value of a numeric marker that best splits a cohort's survival, found by a +// scan of candidate cuts, with the maximum's p corrected for the scan by permutation. Fitted from +// per-patient (marker, time, event) rows inside each local patient-level study. +import type { ViewResult, CutpointView, CutpointScan, CutpointResult } from "../types.ts"; +import { hasViewAggregates, isPatientLevel } from "../federation.ts"; +import { cutpointRowsSql } from "../federation-views.ts"; +import { kaplanMeier, logRank, coxRegression, seededRandom } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { refuse, plural, ATTR_NAME } from "./shared.ts"; + +/** cuts are scanned between these quantiles of the marker, at most CUT_MAX_CANDIDATES of them */ +export const CUT_LOW_Q = 0.1, CUT_HIGH_Q = 0.9, CUT_MAX_CANDIDATES = 100; +const CUT_MAX_STRATA = 8, CUT_MIN_N = 20; + +export interface CutRec { v: number; t: number; ev: number } + +/** + * Maximally selected log-rank statistic (Lausen & Schumacher 1992; R maxstat's LogRank + * statistic): the patients' log-rank scores (event minus the Nelson–Aalen hazard at their time), + * summed over those at or below each candidate cut and standardized by its permutation variance, + * squared. The candidates are the distinct marker values that leave between CUT_LOW_Q and + * CUT_HIGH_Q of the patients at or below them (at most CUT_MAX_CANDIDATES, evenly spaced among + * them). The maximum's p is the share of `permutations` seeded shuffles of the scores over the + * patients' marker order whose own maximum reaches it ((1 + hits) / (1 + permutations)). `recs` + * must come in a fixed order, since the shuffles are drawn over positions. + */ +export function scanCutpoints(recs: CutRec[], permutations: number, seed: number): CutpointScan | null { + const n = recs.length; + if (n < CUT_MIN_N) return null; + // log-rank scores, in time order with ties grouped + const byT = recs.map((_, i) => i).sort((a, b) => recs[a].t - recs[b].t || a - b); + const score = new Float64Array(n); + let H = 0; + for (let i = 0; i < n;) { + let j = i, d = 0; + while (j < n && recs[byT[j]].t === recs[byT[i]].t) { d += recs[byT[j]].ev ? 1 : 0; j++; } + H += d / (n - i); + for (let k = i; k < j; k++) score[byT[k]] = (recs[byT[k]].ev ? 1 : 0) - H; + i = j; + } + const byV = recs.map((_, i) => i).sort((a, b) => recs[a].v - recs[b].v || a - b); + const a = Float64Array.from(byV, (i) => score[i]); + const mean = a.reduce((x, y) => x + y, 0) / n; + const s2 = a.reduce((x, y) => x + (y - mean) ** 2, 0); + if (!(s2 > 0)) return null; + // candidate cuts: the last position of each distinct value inside the quantile window + let ks: number[] = []; + for (let k = 1; k < n; k++) if (recs[byV[k]].v !== recs[byV[k - 1]].v && k >= CUT_LOW_Q * n && k <= CUT_HIGH_Q * n) ks.push(k); + if (!ks.length) return null; + if (ks.length > CUT_MAX_CANDIDATES) ks = Array.from({ length: CUT_MAX_CANDIDATES }, (_, j) => ks[Math.round(j * (ks.length - 1) / (CUT_MAX_CANDIDATES - 1))]); + const varOf = ks.map((k) => k * (n - k) / (n * (n - 1)) * s2); + const stats = (arr: Float64Array): number[] => { + const out: number[] = []; + let cum = 0, at = 0; + for (let j = 0; j < ks.length; j++) { + while (at < ks[j]) cum += arr[at++] - mean; + out.push(cum * cum / varOf[j]); + } + return out; + }; + const obs = stats(a); + let best = 0; + for (let j = 1; j < obs.length; j++) if (obs[j] > obs[best]) best = j; + const maxStat = obs[best]; + const rand = seededRandom(seed), perm = Float64Array.from(a), maxima: number[] = []; + let hits = 0; + for (let b = 0; b < permutations; b++) { + for (let i = n - 1; i > 0; i--) { const j = Math.floor(rand() * (i + 1)); const x = perm[i]; perm[i] = perm[j]; perm[j] = x; } + const m = Math.max(...stats(perm)); + maxima.push(m); + if (m >= maxStat - 1e-9) hits++; + } + maxima.sort((x, y) => x - y); + const k = ks[best], cut = recs[byV[k - 1]].v; + const low = byV.slice(0, k).map((i) => recs[i]), high = byV.slice(k).map((i) => recs[i]); + const lr = logRank([{ a: low.map((r) => ({ t: r.t, event: r.ev })), b: high.map((r) => ({ t: r.t, event: r.ev })) }]); + let hr: CutpointScan["hr"] = null; + try { + const f = coxRegression(recs.map((r) => ({ t: r.t, event: r.ev, x: [r.v > cut ? 1 : 0] }))); + if (Number.isFinite(f.hr[0])) hr = { hr: f.hr[0], ciLow: f.ciLow[0], ciHigh: f.ciHigh[0], p: f.p[0] }; + } catch { hr = null; } + const side = (g: CutRec[]) => { const c = kaplanMeier(g.map((r) => ({ t: r.t, event: r.ev }))); return { n: c.n, events: c.events, median: c.median }; }; + return { + n, events: recs.filter((r) => r.ev).length, cut, lowShare: k / n, maxStat, permP: (1 + hits) / (1 + permutations), + naiveChi2: lr.chi2, naiveP: lr.p, critical95: maxima[Math.min(maxima.length - 1, Math.ceil(0.95 * maxima.length) - 1)] ?? NaN, + hr, low: side(low), high: side(high), + candidates: ks.map((kk, j) => ({ cut: recs[byV[kk - 1]].v, stat: Math.round(obs[j] * 1e4) / 1e4 })), + }; +} + +const canon = (a: CutRec & { s?: string }, b: CutRec & { s?: string }) => + a.v - b.v || a.t - b.t || a.ev - b.ev || ((a.s ?? "") < (b.s ?? "") ? -1 : (a.s ?? "") > (b.s ?? "") ? 1 : 0); + +export async function runCutpoint(view: CutpointView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + if (vNames.length !== 1) return refuse("cutpoint", ctx, "a cut-point is found in one cohort; this view names " + vNames.length + " — choose one"); + const name = vNames[0]; + const attribute = String(view.attribute || "TMB_NONSYNONYMOUS").trim(); + if (!ATTR_NAME.test(attribute)) return refuse("cutpoint", ctx, "“" + attribute.slice(0, 40) + "” is not a clinical attribute's name"); + const endpoint = String(view.endpoint || "OS").toUpperCase().trim(); + if (!/^[A-Z][A-Z0-9_]{0,39}$/.test(endpoint)) return refuse("cutpoint", ctx, "endpoint is a name like OS or PFS"); + if (!!view.monthsAttribute !== !!view.statusAttribute) return refuse("cutpoint", ctx, "monthsAttribute and statusAttribute go together"); + const months = view.monthsAttribute ? String(view.monthsAttribute) : endpoint + "_MONTHS", status = view.statusAttribute ? String(view.statusAttribute) : endpoint + "_STATUS"; + for (const a of [months, status]) if (!ATTR_NAME.test(a)) return refuse("cutpoint", ctx, "“" + a.slice(0, 40) + "” is not an attribute name"); + const endpointLabel = view.monthsAttribute ? months + "/" + status : endpoint; + const permutations = view.permutations == null ? 1000 : Number(view.permutations); + if (!(Number.isInteger(permutations) && permutations >= 100 && permutations <= 5000)) return refuse("cutpoint", ctx, "permutations is a whole number, 100 to 5000"); + const seed = view.seed == null ? 1 : Number(view.seed); + if (!Number.isInteger(seed)) return refuse("cutpoint", ctx, "seed is a whole number"); + const stratifyBy = view.stratifyBy ? String(view.stratifyBy).trim() : null; + if (stratifyBy && !ATTR_NAME.test(stratifyBy)) return refuse("cutpoint", ctx, "“" + stratifyBy.slice(0, 40) + "” is not a clinical attribute's name"); + if (stratifyBy === attribute) return refuse("cutpoint", ctx, "stratifyBy must be a different attribute from the marker"); + const minStratum = view.minStratum == null ? 100 : Number(view.minStratum); + if (!(Number.isInteger(minStratum) && minStratum >= CUT_MIN_N)) return refuse("cutpoint", ctx, "minStratum is a whole number of patients, at least " + CUT_MIN_N); + + const recs: (CutRec & { s?: string })[] = []; + const queries: NonNullable = []; + let noValue = 0; + for (const st of cohorts[name].studies) { + const r = ctx.resolvers[st]; + if (!isPatientLevel(r) || !hasViewAggregates(r)) return refuse("cutpoint", ctx, st + " answers in aggregate only; a permutation test needs each patient's (marker, time, event), which only a patient-level participant returns"); + const mark = r.queries.length; + const rows = await r.viewRows("cutpoint:rows", doc.cohorts[name], (c, s, d) => cutpointRowsSql(c, s, d, { attribute, months, status, stratum: stratifyBy })); + for (const q of r.queries.slice(mark)) queries.push({ study: st, label: q.label, sql: q.sql, ...(q.ms != null ? { ms: q.ms } : {}) }); + for (const x of rows) { + const v = x.v == null ? NaN : Number(x.v); + if (!Number.isFinite(v)) { noValue++; continue; } + recs.push({ v, t: Number(x.m), ev: Number(x.ev) === 1 ? 1 : 0, ...(stratifyBy ? { s: String(x.s ?? "") } : {}) }); + } + } + if (!recs.length) return refuse("cutpoint", ctx, "no patient of “" + name + "” has both a " + endpointLabel + " time and a numeric " + attribute + " value" + + (noValue ? " (" + plural(noValue, "patient") + " with a survival time have none)" : " — check that the study records " + attribute + " and " + months + "/" + status)); + recs.sort(canon); + const overall = scanCutpoints(recs, permutations, seed); + if (!overall) return refuse("cutpoint", ctx, "“" + name + "” has " + plural(recs.length, "patient") + " with a value, " + recs.filter((r) => r.ev).length + + " events; a scan needs at least " + CUT_MIN_N + " patients, some events, and at least two distinct values of " + attribute + " between its 10th and 90th percentiles"); + const km = (g: CutRec[]) => kaplanMeier(g.map((r) => ({ t: r.t, event: r.ev }))); + const result: CutpointResult = { attribute, endpoint: endpointLabel, permutations, seed, noValue, ...overall, + curves: { low: km(recs.filter((r) => r.v <= overall.cut)), high: km(recs.filter((r) => r.v > overall.cut)) } }; + if (stratifyBy) { + const by = new Map(); + for (const r of recs) { const k = r.s || ""; let l = by.get(k); if (!l) by.set(k, l = []); l.push(r); } + const ranked = [...by].filter(([k]) => k).sort((x, y) => y[1].length - x[1].length || (x[0] < y[0] ? -1 : 1)); + const kept = ranked.filter(([, l]) => l.length >= minStratum).slice(0, CUT_MAX_STRATA); + const rows = kept.flatMap(([stratum, l]) => { + const sc = scanCutpoints(l, permutations, seed); + if (!sc) return []; + const { candidates: _c, ...rest } = sc; + return [{ stratum, ...rest }]; + }); + const keptSet = new Set(rows.map((x) => x.stratum)); + const omitted = [...by].filter(([k]) => !keptSet.has(k)); + result.strata = { attribute: stratifyBy, minStratum, rows, omitted: { values: omitted.length, patients: omitted.reduce((a, [, l]) => a + l.length, 0) } }; + } + const pct = (x: number) => Math.round(x * 1000) / 10 + "%"; + return { + kind: "cutpoint", cohorts: vNames, cutpoint: result, queries, + participation: ctx.participationFor(ctx.vStudies, true), + ...(ctx.vStudies.length > 1 ? { studies: ctx.vStudies } : {}), + caveat: "The cut was chosen from these patients to separate their " + endpointLabel + " as much as possible, so the split is optimistic in-sample: its naive log-rank p (" + overall.naiveP.toPrecision(2) + + ") ignores the search and overstates the evidence, and the cut needs validation in an independent cohort before it is used. " + + "Scanned: " + overall.candidates.length + " candidate cuts — the distinct values of " + attribute + " leaving between " + CUT_LOW_Q * 100 + "% and " + CUT_HIGH_Q * 100 + "% of patients at or below them" + + " (at most " + CUT_MAX_CANDIDATES + ", evenly spaced) — by the maximally selected standardized log-rank statistic (Lausen & Schumacher; R maxstat). " + + "The corrected p is from " + permutations + " permutations of the marker over the patients (seed " + seed + "): the share whose own best cut does as well. High is " + attribute + " > " + Number(overall.cut.toPrecision(4)) + + " (" + pct(1 - overall.lowShare) + " of patients). " + + attribute + " is the patient's value, or for a sample attribute the value of the patient's first sample by sample id; " + noValue + " patients with a survival time and no numeric value are left out. " + + "The status '1:…' is an event, anything else censored; times from each study's own baseline" + (ctx.vStudies.length > 1 ? ", pooled across " + ctx.vStudies.length + " studies without harmonizing baselines or panels (a panel-dependent marker such as TMB is not comparable across panels)" : "") + ". " + + (result.strata ? "Per " + stratifyBy + ": the largest " + CUT_MAX_STRATA + " values with at least " + minStratum + " patients each get their own scan and permutation test (same seed); " + + result.strata.omitted.values + " other values (" + result.strata.omitted.patients + " patients) are not scanned. Each stratum's cut is as optimistic as the overall one. " : "") + + "Computed from per-patient rows inside each patient-level study.", + }; +} diff --git a/lib/views/drivernegative.ts b/lib/views/drivernegative.ts new file mode 100644 index 0000000..8745f69 --- /dev/null +++ b/lib/views/drivernegative.ts @@ -0,0 +1,101 @@ +// Driver-negative view: within one cohort, patients with no OncoKB driver in a canonical driver list +// against those with one, and every other gene's alteration frequency compared between the two. +import type { ViewResult, DrivernegativeView, DrivernegativeGene } from "../types.ts"; +import { dnegGroupsSql, dnegAlteredSql, dnegProfiledSql, type EnrichAlteration } from "../federation-views.ts"; +import { fisherExact, benjaminiHochberg } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural, cytobands } from "./shared.ts"; + +/** the canonical NSCLC drivers (targetable oncogenes) */ +export const DNEG_DEFAULT_DRIVERS = ["EGFR", "KRAS", "ALK", "ROS1", "BRAF", "MET", "RET", "ERBB2", "NTRK1", "NTRK2", "NTRK3"]; +export const DNEG_MAX_DRIVERS = 40; +const DNEG_ROWS = 300; +const WORD: Partial> = { mutation: "mutation", sv: "structural variant (either partner)", cna: "amplification or deep deletion" }; + +/** per gene: the two groups' counts, Fisher, log2 of the negative share over the positive one; BH over all. Pure. */ +export function dnegTest(altered: Map, profiled: Map, wes: [number, number], minAltered: number): + { tested: DrivernegativeGene[]; untested: number } { + const tested: DrivernegativeGene[] = []; + let untested = 0; + for (const [gene, alt] of altered) { + const pr = profiled.get(gene) || [0, 0]; + // a patient altered in a gene their panel does not list (an off-panel call) was measured + const prof: [number, number] = [Math.max(alt[0], pr[0] + wes[0]), Math.max(alt[1], pr[1] + wes[1])]; + if (alt[0] + alt[1] < minAltered || !prof[0] || !prof[1]) { untested++; continue; } + const p = fisherExact(alt[0], prof[0] - alt[0], alt[1], prof[1] - alt[1]); + const s0 = alt[0] / prof[0], s1 = alt[1] / prof[1]; + tested.push({ gene, altered: alt, profiled: prof, log2Ratio: s0 > 0 && s1 > 0 ? Math.log2(s0 / s1) : null, p, q: 1 }); + } + benjaminiHochberg(tested.map((g) => g.p)).forEach((q, i) => { tested[i].q = q; }); + tested.sort((a, b) => a.p - b.p || a.gene.localeCompare(b.gene)); + return { tested, untested }; +} + +export async function runDrivernegative(view: DrivernegativeView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + const no = (e: string) => refuse("drivernegative", ctx, e); + if (vNames.length !== 1) return no("a driver-negative view splits one cohort; this one draws " + vNames.length); + if (!pin) return no("no OncoKB annotation pin is available here, so drivers cannot be called"); + const drivers = [...new Set((Array.isArray(view.drivers) && view.drivers.length ? view.drivers : DNEG_DEFAULT_DRIVERS).map((g) => String(g).trim().toUpperCase()).filter(Boolean))]; + { const e = overLimit("a driver-negative view", DNEG_MAX_DRIVERS, drivers.length); if (e) return no(e.replace("at most " + DNEG_MAX_DRIVERS, "at most " + DNEG_MAX_DRIVERS + " driver genes")); } + const badGene = drivers.find((g) => !/^[A-Z0-9_.-]{1,40}$/.test(g)); + if (badGene) return no("'" + badGene.slice(0, 40) + "' is not a gene symbol"); + const alteration = (view.alteration || "mutation") as EnrichAlteration; + if (!WORD[alteration]) return no("alteration is mutation, sv or cna; got " + JSON.stringify(view.alteration).slice(0, 30)); + const measure: "any" | "driver" = view.measure === "driver" ? "driver" : "any"; + const minAltered = view.minAltered == null ? 5 : Math.floor(Number(view.minAltered)); + if (!(minAltered >= 1 && minAltered <= 10000)) return no("minAltered is 1 to 10000"); + const labelN = view.label == null ? 12 : Math.floor(Number(view.label)); + if (!(labelN >= 0 && labelN <= 40)) return no("label is 0 to 40 genes"); + const n = vNames[0]; + if (!cohorts[n].count) return no("nothing to split: cohort “" + n + "” is empty"); + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return no("no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + + const def = doc.cohorts[n]; + const groups = { positive: 0, negative: 0, unprofiled: 0 }; + const altered = new Map(), profiled = new Map(); + const wes: [number, number] = [0, 0]; + const idx = (g: unknown) => String(g) === "negative" ? 0 : 1; + for (const st of cohorts[n].studies.filter((s) => ok[s])) { + const [gr, al, pr] = await Promise.all([ + ok[st].viewAggregate("drivernegative:groups", def, (c, s, d) => dnegGroupsSql(c, s, d, pin, drivers)), + ok[st].viewAggregate("drivernegative:altered", def, (c, s, d) => dnegAlteredSql(c, s, d, pin, drivers, alteration, measure === "driver")), + ok[st].viewAggregate("drivernegative:profiled", def, (c, s, d) => dnegProfiledSql(c, s, d, pin, drivers, alteration))]); + for (const r of gr) { const k = String(r.grp) as keyof typeof groups; if (k in groups) groups[k] += Number(r.n); } + for (const r of al) { const g = String(r.gene); let x = altered.get(g); if (!x) altered.set(g, x = [0, 0]); x[idx(r.grp)] += Number(r.altered); } + for (const r of pr) { + if (String(r.gene) === "") { wes[idx(r.grp)] += Number(r.profiled); continue; } + const g = String(r.gene); let x = profiled.get(g); if (!x) profiled.set(g, x = [0, 0]); x[idx(r.grp)] += Number(r.profiled); + } + } + const sequenced = groups.positive + groups.negative + groups.unprofiled; + if (!sequenced) return no("no patient of “" + n + "” has a sample sequenced for mutations"); + if (!groups.negative || !groups.positive) return no("every fully profiled patient of “" + n + "” is driver-" + (groups.negative ? "negative" : "positive") + " for " + drivers.join(", ") + ", so there is nothing to compare (" + + plural(groups.positive, "driver-positive patient") + ", " + plural(groups.negative, "driver-negative") + ", " + plural(groups.unprofiled, "not fully profiled") + ")"); + const { tested, untested } = dnegTest(altered, profiled, wes, minAltered); + const rows = tested.slice(0, DNEG_ROWS); + const bands = await cytobands(ok, rows.map((g) => g.gene)); + for (const g of rows) { const b = bands.get(g.gene); if (b) g.cytoband = b; } + const significant = tested.filter((g) => g.q < 0.05).length; + const labels = tested.filter((g) => g.q < 0.05).slice(0, labelN).map((g) => g.gene); + const pct = (k: number, d: number) => d ? (100 * k / d).toFixed(1) + "%" : "–"; + const word = WORD[alteration]!; + const multi = ctx.vStudies.length > 1; + return { + kind: "drivernegative", cohorts: vNames, + drivernegative: { drivers, alteration: word, measure, minAltered, patients: cohorts[n].count, sequenced, ...groups, + tested: tested.length, untested, significant, rows, labels, ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "Of the " + plural(sequenced, "patient") + " of “" + n + "” sequenced for mutations, " + groups.positive.toLocaleString("en-US") + " (" + pct(groups.positive, sequenced) + ") carry an OncoKB driver (" + pin + ") — a called mutation, a high-level copy-number call or a structural variant the store calls oncogenic or likely oncogenic — in " + drivers.join(", ") + + "; " + groups.negative.toLocaleString("en-US") + " (" + pct(groups.negative, sequenced) + ") carry none and were sequenced on a panel covering every one of those genes (or whole-exome): the driver-negative group. " + + groups.unprofiled.toLocaleString("en-US") + " (" + pct(groups.unprofiled, sequenced) + ") carry none but were not profiled for every listed gene, so are neither — kept out of both sides, never counted as driver-negative. " + + "Coverage is read from the mutation panels; a fusion driver is called only where the study reports structural variants. " + + "Then every other gene's " + word + (measure === "driver" ? " (OncoKB drivers only)" : "") + " frequency is compared between driver-negative and driver-positive patients, in PATIENTS over the patients profiled for that gene (a panel that lists it, or whole-exome; an off-panel call counts as measured), two-sided Fisher's exact test, Benjamini–Hochberg q over the " + plural(tested.length, "gene") + " tested; " + + untested.toLocaleString("en-US") + " altered genes were not tested (fewer than " + minAltered + " altered patients, or not profiled in a group). The log ratio is log2 of the driver-negative share over the driver-positive one. " + + "Driver-positive tumours are often mutually exclusive with other drivers by biology, so a gene enriched in the driver-negative group is a candidate alternative driver, not a proven one; smoking history and tumour mutation burden also differ between the groups and are not adjusted for. " + + (multi ? "Counts are summed across " + ctx.vStudies.length + " studies; overlap is not deduplicated. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/exclusivity.ts b/lib/views/exclusivity.ts new file mode 100644 index 0000000..f9bfd52 --- /dev/null +++ b/lib/views/exclusivity.ts @@ -0,0 +1,204 @@ +// Exclusivity view: mutual exclusivity and co-occurrence against each tumour's alteration burden +// (DISCOVER, Canisius, Martens & Wessels, Genome Biology 2016) +import type { ViewResult, ExclusivityView, ExclusivityPair, ExclusivityResult } from "../types.ts"; +import { exclusivityGroupsSql, exclusivityColumnsSql, exclusivityCoverageSql } from "../federation-views.ts"; +import { benjaminiHochberg, fisherExact, log2OddsRatio, poissonBinomialTails } from "../stats.ts"; +import { geneSymbols } from "../gene-tracks.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, plural, refuse, ATTR_NAME } from "./shared.ts"; + +const GENES_MAX = 20; +const FIT_TOL = 1e-4, FIT_MAX_ITER = 300; + +/** σ(a + b), where a row or gene that is never altered (−∞) wins over one always altered (+∞) */ +const prob = (a: number, b: number) => a === -Infinity || b === -Infinity ? 0 : a === Infinity || b === Infinity ? 1 : 1 / (1 + Math.exp(-(a + b))); +const logit = (p: number) => Math.log(p / (1 - p)); + +/** one background row: the samples sharing a panel signature and a burden (they share one row parameter) */ +export interface BgRow { sig: string; r: number; n: number } +export interface BgFit { a: number[]; b: Map; iterations: number; converged: boolean; maxResidual: number } + +/** + * DISCOVER's background: p_ij = σ(a_i + b_j) over the genes each sample's panels cover, with a_i + * and b_j chosen so that every sample's expected number of altered genes is its observed burden + * and every gene's expected number of altered samples its observed count (the maximum-likelihood + * logistic model with row and column effects). Fitted by alternating one-dimensional Newton + * steps on rows and columns until the largest residual, relative to its target, is below FIT_TOL. + * A row altered in none (or all) of its genes, or a gene altered in none (or all) of its samples, + * sits at −∞ (+∞): its probabilities are exactly 0 (1). + */ +export function fitBackground(rows: BgRow[], cols: Map, cover: Map>): BgFit { + const genes = [...cols.keys()].filter((g) => (cols.get(g) || 0) > 0); + const gi = new Map(genes.map((g, i) => [g, i])); + const rowGenes = rows.map((r) => [...(cover.get(r.sig) || [])].map((g) => gi.get(g)).filter((x): x is number => x != null)); + const colRows: number[][] = genes.map(() => []); + rowGenes.forEach((js, i) => { for (const j of js) colRows[j].push(i); }); + const c = genes.map((g) => cols.get(g) || 0); + const N = colRows.map((is) => is.reduce((s, i) => s + rows[i].n, 0)); + const a = rows.map((r, i) => r.r <= 0 ? -Infinity : r.r >= rowGenes[i].length ? Infinity : logit(r.r / rowGenes[i].length)); + const b = genes.map((_, j) => c[j] >= N[j] ? Infinity : logit(Math.min(0.999, c[j] / Math.max(1, N[j])))); + let iterations = 0, maxResidual = Infinity; + const newton = (x: number, terms: (at: number) => [number, number], target: number) => { + for (let k = 0; k < 4; k++) { + const [f, d] = terms(x); + if (!(d > 1e-12)) break; + const step = Math.max(-5, Math.min(5, (f - target) / d)); + x -= step; + if (Math.abs(step) < 1e-10) break; + } + return x; + }; + while (iterations < FIT_MAX_ITER && maxResidual > FIT_TOL) { + iterations++; + for (let j = 0; j < genes.length; j++) { + if (!Number.isFinite(b[j])) continue; + b[j] = newton(b[j], (bj) => { + let f = 0, d = 0; + for (const i of colRows[j]) { const s = prob(a[i], bj); f += rows[i].n * s; d += rows[i].n * s * (1 - s); } + return [f, d]; + }, c[j]); + } + for (let i = 0; i < rows.length; i++) { + if (!Number.isFinite(a[i])) continue; + a[i] = newton(a[i], (ai) => { + let f = 0, d = 0; + for (const j of rowGenes[i]) { const s = prob(ai, b[j]); f += s; d += s * (1 - s); } + return [f, d]; + }, rows[i].r); + } + maxResidual = 0; + for (let j = 0; j < genes.length; j++) { + let f = 0; + for (const i of colRows[j]) f += rows[i].n * prob(a[i], b[j]); + maxResidual = Math.max(maxResidual, Math.abs(f - c[j]) / Math.max(1, c[j])); + } + for (let i = 0; i < rows.length; i++) { + let f = 0; + for (const j of rowGenes[i]) f += prob(a[i], b[j]); + maxResidual = Math.max(maxResidual, Math.abs(f - rows[i].r) / Math.max(1, rows[i].r)); + } + } + return { a, b: new Map(genes.map((g, j) => [g, b[j]])), iterations, converged: maxResidual <= FIT_TOL, maxResidual }; +} + +interface Group { stratum: string; sig: string; r: number; amask: number; n: number } + +export async function runExclusivity(view: ExclusivityView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + let genes: string[]; + try { genes = geneSymbols(view.genes || []); } catch (e) { return refuse("exclusivity", ctx, (e as Error).message); } + { const e = overLimit("an exclusivity view", GENES_MAX, genes.length); if (e) return refuse("exclusivity", ctx, e.replace("at most " + GENES_MAX, "at most " + GENES_MAX + " genes")); } + if (genes.length < 2) return refuse("exclusivity", ctx, "an exclusivity view needs at least 2 distinct genes"); + if (!vNames.length) return refuse("exclusivity", ctx, "an exclusivity view needs one cohort"); + const measure: "driver" | "any" = view.measure === "driver" ? "driver" : "any"; + if (measure === "driver" && !pin) + return refuse("exclusivity", ctx, "no OncoKB annotation pin is available here, so oncogenic alterations cannot be told apart — use measure: \"any\""); + const stratifyBy = view.stratifyBy ? String(view.stratifyBy).trim() : null; + if (stratifyBy && !ATTR_NAME.test(stratifyBy)) return refuse("exclusivity", ctx, "stratifyBy must be a sample attribute name; got " + JSON.stringify(stratifyBy).slice(0, 40)); + const cn = vNames[0]; + const { ok, excluded } = answering(ctx); + const studies = cohorts[cn].studies.filter((st) => ok[st]); + if (!studies.length) return refuse("exclusivity", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + + // each study is its own background: its own panels, its own column sums + const groups: Group[] = []; + const colSums = new Map>(); + const cover = new Map>(); + await Promise.all(studies.map(async (st) => { + const def = doc.cohorts[cn]; + const [g, c, v] = await Promise.all([ + ok[st].viewAggregate("exclusivity:groups:" + cn, def, (s, x, d) => exclusivityGroupsSql(s, x, d, genes, stratifyBy, pin, measure)), + ok[st].viewAggregate("exclusivity:columns:" + cn, def, (s, x, d) => exclusivityColumnsSql(s, x, d, stratifyBy, pin, measure)), + ok[st].viewAggregate("exclusivity:coverage:" + cn, def, (s, x, d) => exclusivityCoverageSql(s, x, d)) + ]); + const key = (stratum: unknown) => st + "\u0000" + String(stratum ?? ""); + for (const r of g) groups.push({ stratum: key(r.stratum), sig: st + "\u0000" + String(r.sig), r: Number(r.r), amask: Number(r.amask), n: Number(r.n) }); + for (const r of c) { + const m = colSums.get(key(r.stratum)) || new Map(); + m.set(String(r.gene), Number(r.n)); colSums.set(key(r.stratum), m); + } + for (const r of v) { + const k = st + "\u0000" + String(r.sig), s = cover.get(k) || new Set(); + s.add(String(r.gene)); cover.set(k, s); + } + })); + const samples = groups.reduce((s, g) => s + g.n, 0); + if (!samples) return refuse("exclusivity", ctx, "no sample of “" + cn + "” has a gene panel recorded, so nothing is known to be profiled"); + + // the background, stratum by stratum; then each group's probability per listed gene + const strata = [...new Set(groups.map((g) => g.stratum))].sort(); + const probs = new Map>(); + let iterations = 0, converged = true, maxResidual = 0; + const matrixGenes = new Set(); + for (const s of strata) { + const gs = groups.filter((g) => g.stratum === s); + const rowsByKey = new Map(); + for (const g of gs) { + const k = g.sig + "\u0000" + g.r, row = rowsByKey.get(k) || { sig: g.sig, r: g.r, n: 0 }; + row.n += g.n; rowsByKey.set(k, row); + } + const rows = [...rowsByKey.values()]; + const cols = colSums.get(s) || new Map(); + for (const [g, n] of cols) if (n > 0) matrixGenes.add(g); + const fit = fitBackground(rows, cols, cover); + iterations = Math.max(iterations, fit.iterations); converged = converged && fit.converged; maxResidual = Math.max(maxResidual, fit.maxResidual); + const rowIndex = new Map(rows.map((r, i) => [r.sig + "\u0000" + r.r, i])); + for (const g of gs) { + const ai = fit.a[rowIndex.get(g.sig + "\u0000" + g.r)!], cov = cover.get(g.sig); + probs.set(g, new Map(genes.map((x) => [x, cov && cov.has(x) ? prob(ai, fit.b.get(x) ?? -Infinity) : NaN]))); + } + } + + const pairs: ExclusivityPair[] = []; + const unassessable: string[] = []; + let approximated = 0; + for (let i = 0; i < genes.length; i++) for (let j = i + 1; j < genes.length; j++) { + const A = genes[i], B = genes[j], bitA = 2 ** i, bitB = 2 ** j; + let both = 0, onlyA = 0, onlyB = 0, neither = 0; + const tri = new Map(); + for (const g of groups) { + const pr = probs.get(g)!, pa = pr.get(A)!, pb = pr.get(B)!; + if (Number.isNaN(pa) || Number.isNaN(pb)) continue; + const hasA = (g.amask & bitA) !== 0, hasB = (g.amask & bitB) !== 0; + if (hasA && hasB) both += g.n; else if (hasA) onlyA += g.n; else if (hasB) onlyB += g.n; else neither += g.n; + const q = pa * pb; + tri.set(q, (tri.get(q) || 0) + g.n); + } + const profiled = both + onlyA + onlyB + neither; + if (!profiled) { unassessable.push(A + "/" + B); continue; } + const t = poissonBinomialTails([...tri].map(([p, n]) => ({ n, p })), both); + if (!t.exact) approximated++; + pairs.push({ a: A, b: B, profiled, both, expected: Math.round(t.mean * 100) / 100, pExclusive: t.lower, qExclusive: 1, pCooccur: t.upper, qCooccur: 1, exact: t.exact, + onlyA, onlyB, neither, fisherP: fisherExact(both, onlyA, onlyB, neither), fisherQ: 1, log2Odds: log2OddsRatio(both, onlyA, onlyB, neither) }); + } + if (!pairs.length) return refuse("exclusivity", ctx, "no pair of these genes was profiled together in any sample of “" + cn + "”"); + benjaminiHochberg(pairs.map((x) => x.pExclusive)).forEach((q, k) => { pairs[k].qExclusive = q; }); + benjaminiHochberg(pairs.map((x) => x.pCooccur)).forEach((q, k) => { pairs[k].qCooccur = q; }); + benjaminiHochberg(pairs.map((x) => x.fisherP)).forEach((q, k) => { pairs[k].fisherQ = q; }); + pairs.sort((x, y) => Math.min(x.qExclusive, x.qCooccur) - Math.min(y.qExclusive, y.qCooccur) || (x.a + x.b < y.a + y.b ? -1 : 1)); + + const stratumList = strata.map((s) => ({ name: s.split("\u0000").filter(Boolean).join(" · ") || "all", samples: groups.filter((g) => g.stratum === s).reduce((t, g) => t + g.n, 0) })); + const multi = studies.length > 1; + const result: ExclusivityResult = { cohort: cn, genes, stratifyBy, strata: stratumList, measure, samples, matrixGenes: matrixGenes.size, iterations, converged, + maxResidual, pairs, unassessable, ...(excluded.length ? { excluded } : {}) }; + const what = measure === "driver" ? "OncoKB oncogenic alterations (" + pin + ")" : "any alteration (mutation, amplification or deep deletion, structural variant)"; + const sigExcl = pairs.filter((x) => x.qExclusive < 0.05).length, sigFisherExcl = pairs.filter((x) => x.fisherQ < 0.05 && (x.log2Odds ?? 0) < 0).length; + return { + kind: "exclusivity", cohorts: [cn], exclusivity: result, + participation: ctx.participationFor(studies, false), + ...(multi ? { studies } : {}), + caveat: "Within “" + cn + "”" + (vNames.length > 1 ? " (the first cohort in this view: exclusivity describes one cohort)" : "") + ", over its " + plural(samples, "sample") + + " with a gene panel (every sample of each patient; a sample without a panel record is left out, never read as wild-type), counting " + what + ". " + + "DISCOVER's background gives each sample and gene a probability of alteration, σ(a_sample + b_gene), fitted so that every sample's expected number of altered genes is its observed burden and every gene's expected number of altered samples its observed count — over the whole altered-gene matrix (" + + plural(matrixGenes.size, "gene") + ", each sample's own panels: a gene off a sample's panel has no probability there)" + + (stratifyBy ? ", separately within each " + stratifyBy + " (" + stratumList.length + " strata" + (multi ? ", and each study" : "") + ")" : multi ? ", separately in each of the " + studies.length + " studies" : "") + "; " + + (converged ? "the fit matched every margin to within " + (FIT_TOL * 100) + "% in " + iterations + " rounds. " : "the fit did NOT converge in " + FIT_MAX_ITER + " rounds (largest margin residual " + (maxResidual * 100).toFixed(2) + "%) — read the p-values with care. ") + + "For a pair, the number of samples altered in both under independence given those probabilities is Poisson-binomial with p_i = p_iA·p_iB, over the samples profiled for both; exclusivity is its lower tail at the observed count, co-occurrence its upper tail, each Benjamini–Hochberg across the " + + plural(pairs.length, "pair") + (approximated ? " (" + plural(approximated, "pair") + "' tails from the refined normal approximation, the exact sum being too large)" : ", all exact") + ". " + + "Beside it, the plain two-sided Fisher test on the same samples, which assumes every tumour is equally likely to carry each alteration — heavily altered tumours then manufacture co-occurrence and lightly altered ones exclusivity: here " + + sigExcl + " pairs are exclusive at DISCOVER q < 0.05 against " + sigFisherExcl + " by Fisher. " + + "Exclusivity is a pattern, not a mechanism: synthetic lethality, redundant pathway hits, and subtypes with different drivers (the confounder stratifying removes, when the strata are the right ones) all produce it." + + (unassessable.length ? " Not assessable (no sample profiled for both): " + unassessable.join(", ") + "." : "") + + (excluded.length ? " Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/forest.ts b/lib/views/forest.ts new file mode 100644 index 0000000..dae5452 --- /dev/null +++ b/lib/views/forest.ts @@ -0,0 +1,221 @@ +// Forest view: one exposure's hazard ratio within each stratum (cancer type, study or cohort), each +// from its own Cox fit on per-patient rows inside each local study, pooled by a DerSimonian–Laird +// random-effects meta-analysis. Covariates are coded by ./covariates.ts, as the cox view codes them. +import { coxRegression, dersimonianLaird, COX_MAX_ITERATIONS, type CoxRow } from "../stats.ts"; +import type { ViewResult, ForestView, ForestRow, ForestResult, ForestPooled, CoxCovariate, PlotAxis } from "../types.ts"; +import { isPatientLevel, type EndpointColumns } from "../federation.ts"; +import { validateAxis, axisLabel, isAbsent, OQL_NOT_PROFILED, MULTIPLE, type AxisKind } from "../plot.ts"; +import { resolveProfileAxis } from "../plot-profile.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, refuse, studyCatalogs, plural, ATTR_NAME } from "./shared.ts"; +import { buildCoxColumns, notProfiledNote } from "./covariates.ts"; + +export const FOREST_MIN_PATIENTS = 30, FOREST_MIN_EVENTS = 10, FOREST_MAX_STRATA = 30, FOREST_MAX_COVARIATES = 4; +/** a stratum's fit is reported only when its log-HR's standard error is below this (larger: separation) */ +const FOREST_MAX_SE = 5; +export const FOREST_OTHER = "Other"; + +/** one patient as the fit reads them: time, event, exposed or not, stratum, covariate values */ +export interface ForestPatient { t: number; event: number; exposed: 0 | 1; stratum: string; values: (number | string)[] } +export interface ForestOpts { minPatients: number; minEvents: number; maxStrata: number; small: "pool" | "drop" } + +/** the pooled estimate on the hazard-ratio scale */ +const toHr = (e: { beta: number; se: number; ciLow: number; ciHigh: number; p: number }): ForestPooled => + ({ beta: e.beta, se: e.se, hr: Math.exp(e.beta), ciLow: Math.exp(e.ciLow), ciHigh: Math.exp(e.ciHigh), p: e.p }); + +/** + * The forest's rows from its patients: strata by size, the small ones (and any past `maxStrata`) + * pooled into "Other" (each keeping its own baseline hazard there) or left out; one Cox fit per + * row, exposure first; rows that cannot be estimated listed with the reason; then the fixed- and + * random-effects pools. Pure, so it is testable without a database. + */ +export function forestFit(patients: ForestPatient[], covariates: CoxCovariate[], kinds: AxisKind[], o: ForestOpts): + Pick { + const by = new Map(); + for (const r of patients) { const g = by.get(r.stratum); if (g) g.push(r); else by.set(r.stratum, [r]); } + const events = (rs: ForestPatient[]) => rs.reduce((n, r) => n + r.event, 0); + const groups = [...by].map(([label, rs]) => ({ label, rs, events: events(rs) })) + .sort((a, b) => b.rs.length - a.rs.length || a.label.localeCompare(b.label)); + const big = (g: { rs: ForestPatient[]; events: number }) => g.rs.length >= o.minPatients && g.events >= o.minEvents; + const kept = groups.filter((g, i) => big(g) && i < o.maxStrata), keptSet = new Set(kept); + const small = groups.filter((g) => !keptSet.has(g)); + const left: ForestResult["left"] = []; + const reasonSmall = (g: { rs: ForestPatient[]; events: number }) => big(g) ? "past the " + o.maxStrata + " largest strata" + : "fewer than " + o.minPatients + " patients or " + o.minEvents + " events"; + const candidates: { label: string; rs: ForestPatient[]; pooled?: string[] }[] = kept.map((g) => ({ label: g.label, rs: g.rs })); + if (small.length && o.small === "pool") { + const rs = small.flatMap((g) => g.rs); + const other = { label: FOREST_OTHER + " (" + small.length + " strata)", rs, pooled: small.map((g) => g.label) }; + if (big({ rs, events: events(rs) })) candidates.push(other); + else left.push({ label: other.label, n: rs.length, events: events(rs), reason: "pooled, the small strata still have fewer than " + o.minPatients + " patients or " + o.minEvents + " events" }); + } else for (const g of small) left.push({ label: g.label, n: g.rs.length, events: g.events, reason: reasonSmall(g) }); + + const rows: ForestRow[] = []; + for (const c of candidates) { + const n = c.rs.length, ev = events(c.rs); + const exp = c.rs.filter((r) => r.exposed), expEv = events(exp); + const unexpEv = ev - expEv; + if (!exp.length || exp.length === n || !expEv || !unexpEv) { + left.push({ label: c.label, n, events: ev, reason: !exp.length ? "no exposed patient" : exp.length === n ? "no unexposed patient" : "no event among the " + (!expEv ? "exposed" : "unexposed") }); + continue; + } + // covariates coded within the row; a numeric one that does not vary there has nothing to adjust + const cols = buildCoxColumns(covariates, kinds, c.rs).filter((col) => kinds[col.axisIndex] !== "num" || + new Set(c.rs.map((r) => Number(r.values[col.axisIndex]))).size > 1); + const input: CoxRow[] = c.rs.map((r) => ({ t: r.t, event: r.event, + x: [r.exposed, ...cols.map((col) => kinds[col.axisIndex] === "num" ? Number(r.values[col.axisIndex]) : String(r.values[col.axisIndex]) === col.level ? 1 : 0)], + ...(c.pooled ? { stratum: r.stratum } : {}) })); + let fit; + try { fit = coxRegression(input); } + catch { fit = null; } + if (!fit || fit.iterations >= COX_MAX_ITERATIONS || !(fit.se[0] > 0 && fit.se[0] < FOREST_MAX_SE) || !Number.isFinite(fit.beta[0])) { + left.push({ label: c.label, n, events: ev, reason: "the Cox fit did not converge (separation: the exposure, or a covariate, nearly decides the events here)" }); + continue; + } + rows.push({ label: c.label, n, events: ev, exposed: exp.length, exposedEvents: expEv, + beta: fit.beta[0], se: fit.se[0], hr: fit.hr[0], ciLow: fit.ciLow[0], ciHigh: fit.ciHigh[0], p: fit.p[0], weight: 0, + ...(c.pooled ? { pooled: c.pooled } : {}) }); + } + if (rows.length < 2) return { rows, left, fixed: null, random: null, tau2: 0, q: 0, qDf: 0, qP: 1, i2: 0 }; + const m = dersimonianLaird(rows); + m.weights.forEach((w, i) => { rows[i].weight = w; }); + return { rows, left, fixed: toHr(m.fixed), random: toHr(m.random), tau2: m.tau2, q: m.q, qDf: m.df, qP: m.qP, i2: m.i2 }; +} + +/** an exposure value to 0/1, or null when it cannot be coded */ +function exposureCoder(kind: AxisKind, threshold: number | undefined, exposed: string | undefined): (v: number | string) => 0 | 1 | null { + if (kind === "num") return (v) => Number.isFinite(Number(v)) ? (Number(v) >= threshold! ? 1 : 0) : null; + if (exposed != null) return (v) => String(v) === exposed ? 1 : 0; + return (v) => String(v) === OQL_NOT_PROFILED ? null : isAbsent(String(v)) ? 0 : 1; +} + +/** "TP53 mutated", "ERBB2 amplified or deleted", "KRAS or NRAS mutated" */ +function alterationName(a: { gene?: string; genes?: string[]; by?: string }): string { + const genes = (a.genes?.length ? a.genes : [a.gene || ""]).map((g) => String(g).toUpperCase()).join(" or "); + return genes + (a.by === "cna" ? " amplified or deeply deleted" : a.by === "sv" ? " with a structural variant" : " mutated"); +} + +export async function runForest(view: ForestView, ctx: ViewRun): Promise { + const { doc, cohorts, resolvers, vNames, vStudies } = ctx; + const no = (e: string) => refuse("forest", ctx, e); + const endpoint = String(view.endpoint || "OS").toUpperCase().trim(); + if (!/^[A-Z][A-Z0-9_]{0,39}$/.test(endpoint)) return no("bad survival endpoint '" + endpoint.slice(0, 40) + "'"); + let endpointColumns: EndpointColumns | undefined; + if (view.monthsAttribute != null || view.statusAttribute != null) { + const m = String(view.monthsAttribute || "").trim(), s = String(view.statusAttribute || "").trim(); + if (!ATTR_NAME.test(m) || !ATTR_NAME.test(s)) return no("monthsAttribute and statusAttribute must both be given as attribute names (a study attribute, or an uploaded column as custom:/)"); + endpointColumns = { months: m, status: s }; + } + const endpointLabel = endpointColumns ? endpointColumns.months + "/" + endpointColumns.status : endpoint; + if (!view.exposure) return no("a forest view needs an exposure: a gene's alteration ({source:\"alteration\", gene}), or an attribute with a threshold or a level"); + const covIn = Array.isArray(view.covariates) ? view.covariates : []; + { const e = overLimit("a forest view", FOREST_MAX_COVARIATES, covIn.length); if (e) return no(e.replace("at most " + FOREST_MAX_COVARIATES, "at most " + FOREST_MAX_COVARIATES + " covariates")); } + const strata = String(view.strata ?? "CANCER_TYPE").trim(); + const byStudy = strata.toLowerCase() === "study", byCohort = strata.toLowerCase() === "cohort"; + if (!byStudy && !byCohort && !ATTR_NAME.test(strata)) return no("strata is a categorical attribute, \"study\" or \"cohort\"; got " + JSON.stringify(view.strata).slice(0, 40)); + if (byStudy && vStudies.length < 2) return no("strata \"study\" needs cohorts spanning two or more studies; these span " + vStudies.join(", ")); + if (byCohort && vNames.length < 2) return no("strata \"cohort\" needs two or more cohorts; this view draws " + vNames.length); + const num = (x: unknown, d: number, lo: number, hi: number, what: string): number => { + const n = x == null ? d : Math.floor(Number(x)); + if (!(n >= lo && n <= hi)) throw new Error(what + " is " + lo + " to " + hi); + return n; + }; + let o: ForestOpts; + try { + o = { minPatients: num(view.minPatients, FOREST_MIN_PATIENTS, 5, 100000, "minPatients"), minEvents: num(view.minEvents, FOREST_MIN_EVENTS, 1, 100000, "minEvents"), + maxStrata: num(view.maxStrata, FOREST_MAX_STRATA, 2, 60, "maxStrata"), small: view.small === "drop" ? "drop" : "pool" }; + } catch (e) { return no((e as Error).message); } + const empty = vNames.filter((n) => !cohorts[n].count); + if (empty.length === vNames.length) return no("nothing to fit: " + (vNames.length === 1 ? "the cohort is" : "every cohort is") + " empty"); + + let exposure: PlotAxis, covariates: CoxCovariate[]; + try { + const catalogs = await studyCatalogs(ctx, vStudies); + exposure = resolveProfileAxis(view.exposure, "exposure", catalogs); + validateAxis(exposure, "exposure"); + covariates = covIn.map((cv, i) => ({ ...resolveProfileAxis(cv, "covariate " + (i + 1), catalogs), ...(cv.reference != null ? { reference: cv.reference } : {}) })); + covariates.forEach((cv, i) => validateAxis(cv, "covariate " + (i + 1))); + } catch (e) { return no((e as Error).message); } + if (exposure.source === "none") return no("the exposure axis is \"none\"; name a gene's alteration or an attribute"); + const stratumAxis: PlotAxis[] = byStudy || byCohort ? [] : [{ source: "clinical", attribute: strata }]; + const axes: PlotAxis[] = [exposure, ...stratumAxis, ...covariates]; + const covAt = 1 + stratumAxis.length; + + // per-patient rows from every patient-level participant; the stratum is the attribute, the study or the cohort + const excludedRemote: { study: string; patients: number }[] = []; + const raw: { t: number; event: number; values: (number | string)[]; stratum: string }[] = []; + let kinds: AxisKind[] | null = null, notProfiled = 0, patientLevelTotal = 0; + const parts = byCohort ? vNames.map((n) => [n]) : [vNames]; + for (const names of parts) for (const n of names) for (const st of cohorts[n].studies) { + const res = resolvers[st], count = cohorts[n].perStudy[st]?.count || 0; + if (!isPatientLevel(res)) { excludedRemote.push({ study: st, patients: count }); continue; } + patientLevelTotal += count; + const r = await res.coxRows(doc.cohorts[n], endpoint, axes, endpointColumns); + if (!kinds) kinds = r.kinds; + notProfiled += r.notProfiled; + for (const x of r.rows) raw.push({ t: x.t, event: x.event, values: x.values, + stratum: byCohort ? n : byStudy ? st : String(x.values[1]) }); + } + if (!raw.length) return no(excludedRemote.length && !patientLevelTotal + ? "no patient-level participant could answer: " + excludedRemote.map((e) => e.study).join(", ") + " answer" + (excludedRemote.length === 1 ? "s" : "") + " with counts only, and a Cox fit needs patient rows" + : "no patient had a " + endpointLabel + " time, the exposure" + (stratumAxis.length ? ", a " + strata : "") + (covariates.length ? " and every covariate" : "") + " on record"); + const k = kinds!; + if (stratumAxis.length && k[1] === "num") return no(strata + " is numeric; strata is a categorical attribute, \"study\" or \"cohort\""); + const threshold = view.threshold == null ? undefined : Number(view.threshold); + if (k[0] === "num" && !Number.isFinite(threshold)) return no(axisLabel(exposure) + " is numeric: give a threshold, and patients at or above it are the exposed"); + if (k[0] !== "num" && threshold != null) return no(axisLabel(exposure) + " is categorical: name the exposed level with exposed, not a threshold"); + const levels = [...new Set(raw.map((r) => String(r.values[0])))].sort(); + const exposedLevel = view.exposed == null ? undefined : String(view.exposed); + if (k[0] !== "num" && exposure.source !== "alteration" && exposedLevel == null) + return no(axisLabel(exposure) + " is categorical: name the level counted as exposed with exposed — it takes " + levels.slice(0, 20).join(", ") + (levels.length > 20 ? ", …" : "")); + if (exposedLevel != null && !levels.includes(exposedLevel)) return no("exposed level '" + exposedLevel.slice(0, 40) + "' is not a value of " + axisLabel(exposure) + " here; it takes " + levels.slice(0, 20).join(", ")); + const code = exposureCoder(k[0], threshold, exposedLevel); + const patients: ForestPatient[] = []; + let uncoded = 0; + for (const r of raw) { + const e = code(r.values[0]); + if (e == null || !Number.isFinite(r.t) || r.t < 0) { uncoded++; continue; } + patients.push({ t: r.t, event: r.event, exposed: e, stratum: r.stratum, values: r.values.slice(covAt) }); + } + // a patient whose samples carry different values of the stratum attribute belongs to no one stratum + const multiple = stratumAxis.length ? patients.filter((r) => r.stratum === MULTIPLE).length : 0; + if (multiple) patients.splice(0, patients.length, ...patients.filter((r) => r.stratum !== MULTIPLE)); + const covKinds = k.slice(covAt); + const fit = forestFit(patients, covariates, covKinds, o); + if (!fit.rows.length) return no("no stratum could be estimated: " + fit.left.slice(0, 8).map((l) => l.label + " (" + l.reason + ")").join("; ")); + + const exposedLabel = k[0] === "num" ? axisLabel(exposure) + " ≥ " + threshold : exposedLevel != null ? axisLabel(exposure) + " = " + exposedLevel + : exposure.source === "alteration" && "oql" in exposure ? exposure.oql + " (altered)" + : exposure.source === "alteration" ? alterationName(exposure) : axisLabel(exposure) + " altered"; + const n = patients.length, events = patients.reduce((a, r) => a + r.event, 0); + const missing = Math.max(0, patientLevelTotal - raw.length - notProfiled); + const stratWord = byStudy ? "study" : byCohort ? "cohort" : strata; + const covNames = covariates.map((c) => axisLabel(c)); + const result: ForestResult = { exposure: axisLabel(exposure), exposedLabel, endpoint: endpointLabel, strata: stratWord, covariates: covNames, + ...fit, n, events, notProfiled, missing, ...(excludedRemote.length ? { excludedRemote } : {}) }; + const pct = (x: number) => (100 * x).toFixed(0) + "%"; + const pooledRow = fit.rows.find((r) => r.pooled); + return { + kind: "forest", cohorts: vNames, forest: result, + participation: ctx.participationFor(vStudies, true), + ...(vStudies.length > 1 ? { studies: vStudies } : {}), + caveat: "Hazard ratio of " + endpointLabel + " for " + exposedLabel + " (exposed) against the rest, fit by Cox regression (Efron ties) within each " + stratWord + + (covNames.length ? ", adjusted there for " + covNames.join(", ") + " (coded as the cox view codes covariates, within each row)" : ", univariate") + ". " + + plural(n, "patient") + " and " + plural(events, "event") + " had a time, the exposure" + (stratumAxis.length ? " and a " + strata : "") + (covNames.length ? " and every covariate" : "") + "; " + + fit.rows.length + " " + (fit.rows.length === 1 ? "row was" : "rows were") + " estimated. " + + (exposure.source === "alteration" ? "Panel-aware: a patient not profiled for the gene is left out, never counted as unaltered." + notProfiledNote(notProfiled) + " " : "") + + (missing ? plural(missing, "patient") + " lacked the endpoint, the stratum or a covariate and were left out. " : "") + + (multiple ? plural(multiple, "patient") + " whose samples carry different values of " + strata + " were left out: they belong to no one stratum. " : "") + + (uncoded ? plural(uncoded, "patient") + " had an exposure value that could not be coded and were left out. " : "") + + (pooledRow ? "“" + pooledRow.label + "” pools " + (pooledRow.pooled!.length <= 12 ? pooledRow.pooled!.join(", ") : pooledRow.pooled!.length + " strata") + " (each under " + o.minPatients + " patients or " + o.minEvents + " events, or past the " + o.maxStrata + " largest), fit with a baseline hazard of its own per stratum. " : "") + + (fit.left.length ? "Not estimated: " + fit.left.slice(0, 12).map((l) => l.label + " (" + l.reason + ")").join("; ") + (fit.left.length > 12 ? "; and " + (fit.left.length - 12) + " more" : "") + ". " : "") + + (fit.random + ? "Pooled by inverse variance: fixed effect, and DerSimonian–Laird random effects (τ² " + fit.tau2.toFixed(3) + ", I² " + pct(fit.i2) + ", Cochran's Q " + fit.q.toFixed(1) + " on " + fit.qDf + " df, p " + (fit.qP < 1e-4 ? fit.qP.toExponential(1) : fit.qP.toFixed(3)) + "). " + + "A high I² says the strata disagree beyond chance: the pooled estimate is then an average over different effects, not one effect. " + : "Only one row could be estimated, so nothing is pooled. ") + + (byCohort ? "Cohorts may share patients; a patient in two cohorts counts in both rows, which the pooling treats as independent. " : "") + + (vStudies.length > 1 ? "Clinical vocabularies are not harmonized across studies. " : "") + + "Association, not cause: the exposure travels with stage, treatment and other prognostic factors not adjusted for. Proportional hazards within each row is assumed, not checked." + + (excludedRemote.length ? " Left out: " + excludedRemote.map((e) => e.study + " (" + e.patients + " patients, aggregate-only)").join(", ") + "." : "") + }; +} diff --git a/lib/views/germline.ts b/lib/views/germline.ts new file mode 100644 index 0000000..d72534a --- /dev/null +++ b/lib/views/germline.ts @@ -0,0 +1,136 @@ +// Germline view: per gene, the patients carrying a germline variant, the share of the patients +// sequenced for the gene they make up, and how often their tumour has a somatic second hit in the +// same gene (another mutation, or a deep deletion) against the somatic rate in non-carriers. +import type { ViewResult, GermlineView, GermlineGene, GermlineCounts } from "../types.ts"; +import { germCarriersSql, germCountsSql, germSurvivalSql, ascnStudiesSql } from "../federation-views.ts"; +import { fisherExact, benjaminiHochberg, kaplanMeier, logRankK, type SurvRecord } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural } from "./shared.ts"; + +const GERM_MAX_GENES = 60, GERM_COHORT_LIMIT = 4, KM_MIN = 5; + +const blank = (): GermlineCounts => ({ sequenced: 0, carriers: 0, outsidePanel: 0, carrierSomatic: 0, carrierCnaProfiled: 0, carrierDeletion: 0, carrierEither: 0, + noncarriers: 0, noncarrierSomatic: 0, noncarrierCnaProfiled: 0, noncarrierDeletion: 0, noncarrierEither: 0 }); + +/** One row of germCountsSql added to a gene's counts. Pure. */ +export function germAdd(c: GermlineCounts, r: { mp: number; carrier: number; som: number; cp: number; del: number; n: number }) { + const { n } = r; + if (!r.mp) { if (r.carrier) c.outsidePanel += n; return; } + c.sequenced += n; + const hit = r.som || r.del; + if (r.carrier) { + c.carriers += n; + if (r.som) c.carrierSomatic += n; + if (r.cp) { c.carrierCnaProfiled += n; if (r.del) c.carrierDeletion += n; } + if (hit) c.carrierEither += n; + } else { + c.noncarriers += n; + if (r.som) c.noncarrierSomatic += n; + if (r.cp) { c.noncarrierCnaProfiled += n; if (r.del) c.noncarrierDeletion += n; } + if (hit) c.noncarrierEither += n; + } +} + +export async function runGermline(view: GermlineView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + const no = (e: string) => refuse("germline", ctx, e); + { const e = overLimit("a germline view", GERM_COHORT_LIMIT, vNames.length); if (e) return no(e.replace("at most " + GERM_COHORT_LIMIT, "at most " + GERM_COHORT_LIMIT + " cohorts")); } + const minCarriers = view.minCarriers == null ? 3 : Math.floor(Number(view.minCarriers)); + if (!(minCarriers >= 1 && minCarriers <= 10000)) return no("minCarriers is 1 to 10000"); + const asked = Array.isArray(view.genes) && view.genes.length ? [...new Set(view.genes.map((g) => String(g).trim().toUpperCase()).filter(Boolean))] : null; + if (asked) { + const e = overLimit("a germline view", GERM_MAX_GENES, asked.length); if (e) return no(e.replace("at most " + GERM_MAX_GENES, "at most " + GERM_MAX_GENES + " genes")); + const bad = asked.find((g) => !/^[A-Z0-9_.-]{1,40}$/.test(g)); if (bad) return no("'" + bad.slice(0, 40) + "' is not a gene symbol"); + } + const survGene = view.survival?.gene ? String(view.survival.gene).trim().toUpperCase() : null; + if (survGene && !/^[A-Z0-9_.-]{1,40}$/.test(survGene)) return no("survival.gene '" + survGene.slice(0, 40) + "' is not a gene symbol"); + if (!vNames.some((n) => cohorts[n].count)) return no("nothing to count: " + (vNames.length === 1 ? "the cohort is" : "every cohort is") + " empty"); + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return no("no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + + // the genes: as given, or every gene with enough carriers in some cohort + const carriersBy = new Map(); + for (const n of vNames) { + const per = new Map(); + for (const st of cohorts[n].studies.filter((s) => ok[s])) + for (const r of await ok[st].viewAggregate("germline:carriers:" + n, doc.cohorts[n], (c, s, d) => germCarriersSql(c, s, d))) + per.set(String(r.gene), (per.get(String(r.gene)) || 0) + Number(r.carriers)); + for (const [g, k] of per) carriersBy.set(g, Math.max(carriersBy.get(g) || 0, k)); + } + if (!carriersBy.size) return no("no patient of " + (vNames.length === 1 ? "“" + vNames[0] + "”" : "these cohorts") + " has a germline call (a mutation with mutation status GERMLINE): the study reports none, or does not mark germline variants"); + let genes: string[]; + if (asked) genes = asked; + else { + genes = [...carriersBy].filter(([, k]) => k >= minCarriers).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([g]) => g); + if (!genes.length) return no("no gene has " + minCarriers + " or more germline carriers in a cohort; the most are " + + [...carriersBy].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([g, k]) => g + " (" + k + ")").join(", ")); + const e = overLimit("a germline view", GERM_MAX_GENES, genes.length); + if (e) return no(plural(genes.length, "gene") + " have " + minCarriers + " or more carriers; " + e.replace("at most " + GERM_MAX_GENES, "at most " + GERM_MAX_GENES + " genes") + " — raise minCarriers or name the genes"); + } + if (survGene && !genes.includes(survGene)) genes = [...genes, survGene]; + + const first = Object.values(ok)[0]; + const ascn = new Set((await first.viewMeta("germline:ascn", (s, d) => ascnStudiesSql(s, d)).catch(() => [])).map((r) => String(r.study))); + const panels: { cohort: string; patients: number; genes: GermlineGene[] }[] = []; + const survival: NonNullable = []; + for (const n of vNames) { + const counts = new Map(genes.map((g) => [g, blank()])); + const recs: [Map, Map] = [new Map(), new Map()]; + for (const st of cohorts[n].studies.filter((s) => ok[s])) { + const [rows, surv] = await Promise.all([ + ok[st].viewAggregate("germline:counts:" + n, doc.cohorts[n], (c, s, d) => germCountsSql(c, s, d, genes)), + survGene ? ok[st].viewAggregate("germline:survival:" + n, doc.cohorts[n], (c, s, d) => germSurvivalSql(c, s, d, survGene)) : Promise.resolve([])]); + for (const r of rows) { + const c = counts.get(String(r.gene)); if (!c) continue; + germAdd(c, { mp: Number(r.mp), carrier: Number(r.carrier), som: Number(r.som), cp: Number(r.cp), del: Number(r.del), n: Number(r.n) }); + } + for (const r of surv) { + const side = recs[Number(r.carrier) ? 0 : 1]; + let a = side.get(st); if (!a) side.set(st, a = []); + for (let j = 0; j < Number(r.n); j++) a.push({ t: Number(r.t), event: Number(r.dead) ? 1 : 0 }); + } + } + const rows: GermlineGene[] = genes.map((gene) => { + const c = counts.get(gene)!; + const tested = c.carriers > 0 && c.noncarriers > 0; + return { gene, ...c, p: tested ? fisherExact(c.carrierEither, c.carriers - c.carrierEither, c.noncarrierEither, c.noncarriers - c.noncarrierEither) : null, q: null }; + }); + const t = rows.filter((g) => g.p != null); + benjaminiHochberg(t.map((g) => g.p!)).forEach((q, i) => { t[i].q = q; }); + rows.sort((a, b) => b.carriers - a.carriers || a.gene.localeCompare(b.gene)); + panels.push({ cohort: n, patients: cohorts[n].count, genes: rows }); + if (survGene) { + const car = [...recs[0].values()].flat(), non = [...recs[1].values()].flat(); + const curves = [{ group: survGene + " carriers", ...kaplanMeier(car) }, { group: "non-carriers", ...kaplanMeier(non) }]; + let logrank = null, noTest: string | undefined; + if (car.length < KM_MIN || non.length < KM_MIN) noTest = "fewer than " + KM_MIN + " patients with OS in a group (" + car.length + " carriers, " + non.length + " non-carriers)"; + else { + const studies = [...new Set([...recs[0].keys(), ...recs[1].keys()])].sort(); + logrank = logRankK(studies.map((st) => ({ groups: [recs[0].get(st) || [], recs[1].get(st) || []] }))); + } + survival.push({ cohort: n, gene: survGene, curves, logrank, ...(noTest ? { noTest } : {}) }); + } + } + const multi = ctx.vStudies.length > 1; + const withAscn = ctx.vStudies.filter((s) => ascn.has(s)); + const outside = panels.reduce((a, pn) => a + pn.genes.reduce((b, g) => b + g.outsidePanel, 0), 0); + return { + kind: "germline", cohorts: vNames, + germline: { genes, minCarriers, ...(asked ? {} : { chosen: "every gene with ≥ " + minCarriers + " carriers" }), panels, + ...(survGene ? { survival } : {}), loh: withAscn.length ? "not used; " + withAscn.join(", ") + " has allele-specific copy number (FACETS) the view does not read yet" : "not assessed", + ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "Carriers are patients with a mutation the study marks GERMLINE (mutation status, case-insensitive) in the gene, counted per patient over all their samples and only among the patients sequenced for the gene (a tumour mutation panel listing it, or whole-exome); " + + (outside ? plural(outside, "carrier") + " whose tumour panel does not list the gene are left out and counted. " : "none fell outside a panel. ") + + "A study reports germline variants only for the patients it analysed and consented for it, and the warehouse cannot tell a patient tested and negative from one never tested, so the carrier share is of patients sequenced, not of patients germline-tested: a lower bound where germline testing was partial. " + + "A somatic second hit in a carrier is another mutation in the same gene that is not germline (any called status but GERMLINE; uncalled and off-panel calls excluded) or a deep deletion (copy number −2, among patients profiled for copy number). " + + "Loss of heterozygosity — the commonest second hit — needs allele-specific copy number and is " + (withAscn.length ? "not used here (" + withAscn.join(", ") + " has FACETS calls the view does not read yet)" : "not assessed") + + "; a deep deletion in a carrier is rare, since it removes the germline allele too. So the second-hit rate is a floor. " + + "Non-carriers' rate is the same somatic definition among the sequenced patients without a germline call; Fisher's exact test of carriers' against non-carriers' rate per gene, Benjamini–Hochberg over the genes of each cohort. " + + "Germline calls are not pathogenicity-filtered here: whatever the study deposited as germline is counted. " + + (survGene ? "Overall survival of the patients sequenced for " + survGene + ", carriers against non-carriers, from OS_MONTHS / OS_STATUS (Kaplan–Meier, log-rank" + (multi ? " stratified by study" : "") + "); not corrected for left truncation or for cancer type and stage. " : "") + + (multi ? "Counts are summed across " + ctx.vStudies.length + " studies; overlap is not deduplicated. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/hla.ts b/lib/views/hla.ts new file mode 100644 index 0000000..459fb93 --- /dev/null +++ b/lib/views/hla.ts @@ -0,0 +1,188 @@ +// HLA view: class I homozygosity and HLA loss of heterozygosity per cohort, and overall survival by +// each (Chowell et al. 2018; McGranahan et al. 2017). Per-patient rows from patient-level studies. +import type { ViewResult, HlaView, HlaPanel, HlaSplit, HlaHr, HlaResult } from "../types.ts"; +import { hasViewAggregates, isPatientLevel } from "../federation.ts"; +import { hlaRowsSql, hlaAttributesSql, type HlaRowsOpts } from "../federation-views.ts"; +import { kaplanMeier, logRank, coxRegression, type SurvRecord } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, plural, refuse, ATTR_NAME } from "./shared.ts"; + +const LOCI = ["A", "B", "C"] as const; +/** fewer patients than this in either group and that split is not drawn */ +const KM_MIN_GROUP = 5; +/** a categorical covariate keeps its commonest levels, the rest pooled as "other" */ +const COV_MAX_LEVELS = 10; +const LOH_DEFAULT = ["HLA_A1_LOH", "HLA_A2_LOH", "HLA_B1_LOH", "HLA_B2_LOH", "HLA_C1_LOH", "HLA_C2_LOH", "HLA_LOH"]; +const LOSS_DEFAULT = ["Loss", "Yes", "LOH", "True"]; + +/** the allele layouts the public studies use: HLA-A1/HLA-A2 (MSK-IMPACT), HLA_A1_ALLELE(S) (NSCLC), HLA_A = "A0201/A2402" (melanoma) */ +const defaultLocus = (x: string) => ({ + first: ["HLA-" + x + "1", "HLA_" + x + "1", "HLA_" + x + "1_ALLELE", "HLA_" + x + "1_ALLELES"], + second: ["HLA-" + x + "2", "HLA_" + x + "2", "HLA_" + x + "2_ALLELE", "HLA_" + x + "2_ALLELES"], + pair: ["HLA_" + x, "HLA-" + x], +}); + +/** one patient: homozygous per locus (null: not typed there), typed fields, HLA LOH (null: no call), the endpoint, covariates */ +export interface HlaRec { + hom: (number | null)[]; nf: number | null; loss: number | null; + t: number | null; ev: number; cov: (string | null)[]; study: string; +} + +const hrOf = (rows: { t: number; event: number; x: number[]; stratum?: string }[]): HlaHr | null => { + if (!rows.length) return null; + try { + const f = coxRegression(rows); + if (!Number.isFinite(f.hr[0]) || !Number.isFinite(f.ciLow[0])) return null; + return { hr: f.hr[0], ciLow: f.ciLow[0], ciHigh: f.ciHigh[0], p: f.p[0], n: f.n, events: f.events }; + } catch { return null; } +}; + +/** covariate columns: a numeric covariate as it is; a categorical one as indicators against its commonest level */ +export function covariateColumns(recs: HlaRec[], k: number): ((r: HlaRec) => number[])[] { + const cols: ((r: HlaRec) => number[])[] = []; + for (let i = 0; i < k; i++) { + const vals = recs.map((r) => r.cov[i]); + if (vals.every((v) => v != null && Number.isFinite(Number(v)))) { cols.push((r) => [Number(r.cov[i])]); continue; } + const counts = new Map(); + for (const v of vals) counts.set(String(v), (counts.get(String(v)) || 0) + 1); + const levels = [...counts].sort((a, b) => b[1] - a[1] || (a[0] < b[0] ? -1 : 1)).map(([l]) => l); + const kept = levels.slice(0, COV_MAX_LEVELS), pooled = levels.length > COV_MAX_LEVELS; + const dummies = [...kept.slice(1), ...(pooled ? ["\u0000other"] : [])]; + cols.push((r) => { + const v = String(r.cov[i]), lv = kept.includes(v) ? v : "\u0000other"; + return dummies.map((d) => d === lv ? 1 : 0); + }); + } + return cols; +} + +/** exposed against not: KM curves, log-rank stratified by study, Cox HR (stratified by study), and adjusted for the covariates */ +export function hlaSplit(recs: HlaRec[], exposed: (r: HlaRec) => boolean, labels: [string, string], k: number, multi: boolean): + { split: HlaSplit | null; why?: string; noCovariate: number } { + const a = recs.filter(exposed), b = recs.filter((r) => !exposed(r)); + if (a.length < KM_MIN_GROUP || b.length < KM_MIN_GROUP) + return { split: null, why: "fewer than " + KM_MIN_GROUP + " patients with the endpoint in a group (" + a.length + " " + labels[0] + ", " + b.length + " " + labels[1] + ")", noCovariate: 0 }; + const sv = (r: HlaRec): SurvRecord => ({ t: r.t!, event: r.ev }); + const studies = [...new Set(recs.map((r) => r.study))].sort(); + const lr = logRank(studies.map((s) => ({ a: a.filter((r) => r.study === s).map(sv), b: b.filter((r) => r.study === s).map(sv) }))); + const base = (r: HlaRec) => ({ t: r.t!, event: r.ev, ...(multi ? { stratum: r.study } : {}) }); + const hr = hrOf(recs.map((r) => ({ ...base(r), x: [exposed(r) ? 1 : 0] }))); + let adjusted: HlaHr | null = null, noCovariate = 0; + if (k) { + const full = recs.filter((r) => r.cov.every((c) => c != null && c !== "")); + noCovariate = recs.length - full.length; + const cols = covariateColumns(full, k); + adjusted = hrOf(full.map((r) => ({ ...base(r), x: [exposed(r) ? 1 : 0, ...cols.flatMap((c) => c(r))] }))); + } + return { + split: { curves: [{ label: labels[0], ...kaplanMeier(a.map(sv)) }, { label: labels[1], ...kaplanMeier(b.map(sv)) }], logrank: lr, hr, adjusted }, + noCovariate, + }; +} + +/** a cohort's genotype and LOH counts and its two survival splits */ +export function hlaPanel(cohort: string, patients: number, recs: HlaRec[], fields: number, k: number, multi: boolean): HlaPanel { + const typed = recs.filter((r) => r.hom.every((h) => h != null)); + const homozygous: [number, number, number, number] = [0, 0, 0, 0]; + for (const r of typed) homozygous[r.hom.reduce((s: number, h) => s + (h || 0), 0)]++; + const withOs = (r: HlaRec) => r.t != null && Number.isFinite(r.t); + const homRecs = typed.filter(withOs), lohRecs = recs.filter((r) => r.loss != null && withOs(r)); + const h = hlaSplit(homRecs, (r) => r.hom.some((x) => x === 1), ["homozygous at ≥1 locus", "heterozygous at all"], k, multi); + const l = !lohRecs.length ? { split: null, why: "no patient here has an HLA LOH call with the endpoint", noCovariate: 0 } : hlaSplit(lohRecs, (r) => r.loss === 1, ["HLA LOH", "no HLA LOH"], k, multi); + return { + cohort, patients, + anyTyped: recs.filter((r) => r.hom.some((x) => x != null) || r.nf != null && r.nf < 99).length, + typed: typed.length, homozygous, + loci: LOCI.map((locus, i) => ({ locus, typed: recs.filter((r) => r.hom[i] != null).length, homozygous: recs.filter((r) => r.hom[i] === 1).length })), + coarser: fields < 99 ? typed.filter((r) => r.nf != null && r.nf < fields).length : 0, + lohEvaluated: recs.filter((r) => r.loss != null).length, loh: recs.filter((r) => r.loss === 1).length, + byHomozygosity: h.split, ...(h.why ? { byHomozygosityWhy: h.why } : {}), + byLoh: l.split, ...(l.why ? { byLohWhy: l.why } : {}), + noOutcome: recs.filter((r) => !withOs(r)).length, + noCovariate: Math.max(h.noCovariate, l.noCovariate), + }; +} + +export async function runHla(view: HlaView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + { const e = overLimit("an hla view", 3, vNames.length); if (e) return refuse("hla", ctx, e.replace("at most 3", "at most 3 cohorts")); } + const resolution = view.resolution == null ? 2 : view.resolution; + if (resolution !== 1 && resolution !== 2 && resolution !== "full") return refuse("hla", ctx, "resolution is 1 (allele group), 2 (protein, the default) or \"full\"; got " + JSON.stringify(view.resolution).slice(0, 20)); + const fields = resolution === "full" ? 99 : resolution; + const loci: HlaRowsOpts["loci"] = []; + for (const x of LOCI) { + const given = view.alleles && view.alleles[x]; + if (given == null) { loci.push(defaultLocus(x)); continue; } + if (!Array.isArray(given) || given.length < 1 || given.length > 2) return refuse("hla", ctx, "alleles." + x + " is [first, second] attributes, or [one attribute holding both]"); + for (const a of given) if (!ATTR_NAME.test(String(a))) return refuse("hla", ctx, "alleles." + x + ": “" + String(a).slice(0, 40) + "” is not an attribute name"); + loci.push(given.length === 2 ? { first: [String(given[0])], second: [String(given[1])], pair: [] } : { first: [], second: [], pair: [String(given[0])] }); + } + const list = (v: string[] | undefined, d: string[]) => (Array.isArray(v) && v.length ? v : d).map((x) => String(x).trim()).filter(Boolean); + const lohAttributes = list(view.lohAttributes, LOH_DEFAULT), lohValues = list(view.lohValues, LOSS_DEFAULT); + if (lohAttributes.length > 12 || lohValues.length > 10) return refuse("hla", ctx, "lohAttributes takes at most 12 names and lohValues at most 10 values"); + for (const a of lohAttributes) if (!ATTR_NAME.test(a)) return refuse("hla", ctx, "lohAttributes: “" + a.slice(0, 40) + "” is not an attribute name"); + for (const v of lohValues) if (!/^[A-Za-z0-9_ .:-]{1,40}$/.test(v)) return refuse("hla", ctx, "lohValues: “" + v.slice(0, 40) + "” has characters it cannot"); + const endpoint = String(view.endpoint || "OS").toUpperCase().trim(); + if (!/^[A-Z][A-Z0-9_]{0,39}$/.test(endpoint)) return refuse("hla", ctx, "endpoint is a name like OS or PFS"); + if (!!view.monthsAttribute !== !!view.statusAttribute) return refuse("hla", ctx, "monthsAttribute and statusAttribute go together"); + const months = view.monthsAttribute ? String(view.monthsAttribute) : endpoint + "_MONTHS", status = view.statusAttribute ? String(view.statusAttribute) : endpoint + "_STATUS"; + for (const a of [months, status]) if (!ATTR_NAME.test(a)) return refuse("hla", ctx, "“" + a.slice(0, 40) + "” is not an attribute name"); + const endpointLabel = view.monthsAttribute ? months + "/" + status : endpoint; + const covariates = list(view.covariates, []); + { const e = overLimit("an hla view", 4, covariates.length); if (e) return refuse("hla", ctx, e.replace("at most 4", "at most 4 covariates")); } + for (const a of covariates) if (!ATTR_NAME.test(a)) return refuse("hla", ctx, "covariates: “" + a.slice(0, 40) + "” is not an attribute name"); + const opts: HlaRowsOpts = { loci, fields, lohAttributes, lohValues, months, status, covariates }; + const multi = ctx.vStudies.length > 1; + + const panels: HlaPanel[] = []; + const queries: NonNullable = []; + for (const n of vNames) { + const recs: HlaRec[] = []; + for (const st of cohorts[n].studies) { + const r = ctx.resolvers[st]; + if (!isPatientLevel(r) || !hasViewAggregates(r)) return refuse("hla", ctx, st + " answers in aggregate only; the HLA view fits each patient's genotype against their survival, which only a patient-level participant returns"); + const mark = r.queries.length; + const rows = await r.viewRows("hla:rows:" + n, doc.cohorts[n], (c, s, d) => hlaRowsSql(c, s, d, opts)); + for (const q of r.queries.slice(mark)) queries.push({ study: st, label: q.label, sql: q.sql, ...(q.ms != null ? { ms: q.ms } : {}) }); + const num = (x: unknown) => x == null ? null : Number(x); + for (const x of rows) recs.push({ + hom: [num(x.hA), num(x.hB), num(x.hC)], nf: num(x.nf), loss: num(x.loss), + t: num(x.m), ev: Number(x.ev) === 1 ? 1 : 0, cov: covariates.map((_, i) => x["c" + i] == null || x["c" + i] === "" ? null : String(x["c" + i])), study: st, + }); + } + panels.push(hlaPanel(n, cohorts[n].count, recs, fields, covariates.length, multi)); + } + if (!panels.some((pn) => pn.anyTyped)) { + const found: string[] = []; + for (const st of ctx.vStudies) { + const r = ctx.resolvers[st]; + if (!hasViewAggregates(r)) continue; + const rows = await r.viewAggregate("hla:attributes", doc.cohorts[vNames[0]], (c, s, d) => hlaAttributesSql(c, s, d)); + found.push(st + (rows.length ? " has " + rows.map((x) => String(x.name) + " (" + x.type + ")").join(", ") : " has no attribute naming HLA")); + } + const lohOnly = panels.some((pn) => pn.lohEvaluated); + return refuse("hla", ctx, "no patient in " + (vNames.length === 1 ? "this cohort" : "these cohorts") + " has an HLA class I allele recorded" + + (lohOnly ? " (some have an HLA LOH call, but homozygosity needs the alleles)" : "") + + " — looked for " + LOCI.map((x, i) => [...loci[i].first, ...loci[i].second, ...loci[i].pair].join("/")).join("; ") + ". " + found.join("; ") + + ". Name a study's attributes with alleles: {A: [first, second], …} or {A: [both]}"); + } + const result: HlaResult = { resolution, endpoint: endpointLabel, covariates, panels }; + const pct = (a: number, b: number) => b ? Math.round(1000 * a / b) / 10 + "%" : "—"; + const resText = resolution === 1 ? "allele group (1 field, e.g. A*02)" : resolution === 2 ? "protein level (2 fields, e.g. A*02:01)" : "every field the study records"; + return { + kind: "hla", cohorts: vNames, hla: result, queries, + participation: ctx.participationFor(ctx.vStudies, true), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "A locus is homozygous when its two alleles match at the " + resText + "; homozygosity is counted over patients typed at all three class I loci (HLA-A, -B, -C). " + + panels.map((pn) => pn.cohort + ": " + plural(pn.typed, "patient") + " fully typed of " + plural(pn.patients, "patient") + ", " + + pct(pn.homozygous[1] + pn.homozygous[2] + pn.homozygous[3], pn.typed) + " homozygous at ≥1 locus; HLA LOH in " + plural(pn.loh, "patient") + " of " + pn.lohEvaluated.toLocaleString("en-US") + + " with a call (" + pct(pn.loh, pn.lohEvaluated) + ")" + (pn.coarser ? "; " + plural(pn.coarser, "patient") + " typed at fewer fields than asked, compared at what they have" : "")).join("; ") + ". " + + "HLA LOH is any of " + lohAttributes.join("/") + " = " + lohValues.join("/") + " in any of the patient's samples; a patient with no call in any sample is left out of that split, and a homozygous locus cannot show LOH (LOHHLA calls only heterozygous loci), so the LOH split is not independent of the genotype. " + + endpointLabel + " is as each study records it, from its own origin, which is a checkpoint-blockade start only in a study of treated patients that measures from it (the melanoma immunotherapy studies do; MSK-IMPACT measures from diagnosis or sequencing). Chowell et al. report worse survival with homozygosity in patients treated with checkpoint blockade, so in a cohort not restricted to them this reads prognosis, not response. " + + "Hazard ratios are Cox (Efron ties), exposed against not" + (multi ? ", stratified by study, with the log-rank test stratified the same way" : "") + "; " + + (covariates.length ? "the adjusted HR adds " + covariates.join(", ") + " (a number as it is, a category as indicators against its commonest value, past the " + COV_MAX_LEVELS + " commonest pooled as other; the patient's value, or their first sample's), on the patients with every covariate. " : "no covariates: the HR is unadjusted, and tumour mutational burden and cancer type are known to confound it. ") + + "HLA supertypes and evolutionary divergence (HED) are not computed. " + + (multi ? "Pooled across " + ctx.vStudies.length + " studies whose typing methods and resolutions differ. " : "") + + "Computed from per-patient rows inside each patient-level study.", + }; +} diff --git a/lib/views/index.ts b/lib/views/index.ts index 7247ef0..1803ef4 100644 --- a/lib/views/index.ts +++ b/lib/views/index.ts @@ -32,6 +32,23 @@ import { runTable } from "./table.ts"; import { runMutations } from "./mutations.ts"; import { runAlterations } from "./alterations.ts"; import { runTimeline } from "./timeline.ts"; +import { runAcquired } from "./acquired.ts"; +import { runCtdna } from "./ctdna.ts"; +import { runExclusivity } from "./exclusivity.ts"; +import { runTxendpoints } from "./txendpoints.ts"; +import { runMultistate } from "./multistate.ts"; +import { runRmst } from "./rmst.ts"; +import { runPredictive } from "./predictive.ts"; +import { runCutpoint } from "./cutpoint.ts"; +import { runHla } from "./hla.ts"; +import { runForest } from "./forest.ts"; +import { runAdjusted } from "./adjusted.ts"; +import { runInstability } from "./instability.ts"; +import { runActionability } from "./actionability.ts"; +import { runDrivernegative } from "./drivernegative.ts"; +import { runOrganotropism } from "./organotropism.ts"; +import { runMolsubtype } from "./molsubtype.ts"; +import { runGermline } from "./germline.ts"; /** * A runner that lets a throw stand for its error: an error is reported as the view's, with its @@ -79,6 +96,23 @@ const RUNNERS: Record = { mutations: drawn(runMutations), alterations: drawn(runAlterations), timeline: drawn(runTimeline), + acquired: drawn(runAcquired), + ctdna: drawn(runCtdna), + exclusivity: drawn(runExclusivity), + txendpoints: drawn(runTxendpoints), + multistate: drawn(runMultistate), + rmst: drawn(runRmst), + predictive: drawn(runPredictive), + cutpoint: drawn(runCutpoint), + hla: drawn(runHla), + forest: drawn(runForest), + adjusted: drawn(runAdjusted), + instability: drawn(runInstability), + actionability: drawn(runActionability), + drivernegative: drawn(runDrivernegative), + organotropism: drawn(runOrganotropism), + molsubtype: drawn(runMolsubtype), + germline: drawn(runGermline), }; /** the runner for a view kind, or undefined for a kind runAnalysis does not know */ diff --git a/lib/views/instability.ts b/lib/views/instability.ts new file mode 100644 index 0000000..dfb0ae1 --- /dev/null +++ b/lib/views/instability.ts @@ -0,0 +1,100 @@ +// Instability view: fraction of the genome altered, arm-level calls and the aneuploidy score, from segments +import type { ViewResult, InstabilityView, InstabilityPanel, InstabilitySpread } from "../types.ts"; +import { genomeCoverageSql, studyGenomeSql, instabilityScoresSql, instabilityArmsSql, instabilityArms } from "../federation-views.ts"; +import { kruskalWallis, mannWhitneyU, quantilesFromHistogram, type Histogram } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, plural, refuse } from "./shared.ts"; + +const COHORT_LIMIT = 6; + +function spread(counts: number[]): InstabilitySpread { + const hist: Histogram[] = counts.map((n, v) => ({ v, n })).filter((h) => h.n > 0); + const N = hist.reduce((s, h) => s + h.n, 0); + const [p5, q1, median, q3, p95] = quantilesFromHistogram(hist, [0.05, 0.25, 0.5, 0.75, 0.95]); + return { p5, q1, median, q3, p95, mean: N ? Math.round(1000 * hist.reduce((s, h) => s + h.v * h.n, 0) / N) / 1000 : null }; +} + +export async function runInstability(view: InstabilityView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + { const e = overLimit("an instability view", COHORT_LIMIT, vNames.length); if (e) return refuse("instability", ctx, e.replace("at most " + COHORT_LIMIT, "at most " + COHORT_LIMIT + " cohorts")); } + const threshold = view.threshold == null ? 0.2 : Number(view.threshold); + if (!(Number.isFinite(threshold) && threshold >= 0.05 && threshold <= 2)) return refuse("instability", ctx, "threshold is a |log2 segment mean| from 0.05 to 2; got " + String(view.threshold).slice(0, 20)); + const armShare = view.armShare == null ? 0.5 : Number(view.armShare); + if (!(Number.isFinite(armShare) && armShare >= 0.25 && armShare <= 0.9)) return refuse("instability", ctx, "armShare is a share of the arm from 0.25 to 0.9; got " + String(view.armShare).slice(0, 20)); + const includeX = view.includeX === true; + const o = { threshold, armShare, includeX }; + + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return refuse("instability", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + // the arm boundaries are hg19 coordinates: another build's segments would be cut in the wrong places + const builds: Record = {}; + await Promise.all(Object.entries(ok).map(async ([st, r]) => { + const rows = await r.viewMeta("instability:build", studyGenomeSql); + builds[st] = String(rows[0]?.build || "unknown"); + })); + const off = Object.entries(builds).filter(([, b]) => b !== "hg19"); + if (off.length) + return refuse("instability", ctx, "the arm boundaries here are hg19's, so segments on another reference genome cannot be scored: " + + Object.entries(builds).map(([st, b]) => st + " " + b).join(", ") + (Object.keys(builds).length > off.length ? " — draw the hg19 studies in their own view" : "")); + + const arms = instabilityArms(includeX); + const nArms = arms.length; + const panels: InstabilityPanel[] = []; + const noSegments: string[] = []; + for (const n of vNames) { + const score = new Array(nArms + 1).fill(0), fga = new Array(101).fill(0); + const byArm = new Map(arms.map((a) => [a, { arm: a, measured: 0, gain: 0, loss: 0 }])); + let samples = 0, segmented = 0, allArms = 0; + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + const def = doc.cohorts[n]; + const [cov, sc, ar] = await Promise.all([ + ok[st].viewAggregate("instability:coverage:" + n, def, genomeCoverageSql), + ok[st].viewAggregate("instability:scores:" + n, def, (c, s, d) => instabilityScoresSql(c, s, d, o)), + ok[st].viewAggregate("instability:arms:" + n, def, (c, s, d) => instabilityArmsSql(c, s, d, o)) + ]); + samples += Number(cov[0]?.samples || 0); + const segd = Number(cov[0]?.segmented || 0); + segmented += segd; + if (!segd && (cohorts[n].perStudy[st]?.count || 0) > 0) noSegments.push(st); + for (const r of sc) { + const k = Number(r.n), s = Math.min(nArms, Number(r.score)), f = Math.min(100, Math.max(0, Number(r.fga_bin))); + score[s] += k; fga[f] += k; + if (Number(r.measured) >= nArms) allArms += k; + } + for (const r of ar) { + const a = byArm.get(String(r.arm)); + if (!a) continue; + a.measured += Number(r.n_measured); a.gain += Number(r.n_gain); a.loss += Number(r.n_loss); + } + })); + panels.push({ cohort: n, samples, segmented, allArms, score, fga, scoreSpread: spread(score), fgaSpread: spread(fga), arms: arms.map((a) => byArm.get(a)!) }); + } + const scored = (pn: InstabilityPanel) => pn.score.reduce((s, x) => s + x, 0); + if (!panels.some(scored)) + return refuse("instability", ctx, "no sample of " + (vNames.length === 1 ? "this cohort" : "these cohorts") + " has copy-number segments" + + (noSegments.length ? " (" + [...new Set(noSegments)].join(", ") + " has none for these patients)" : "")); + const drawn = panels.filter(scored); + const hist = (xs: number[]) => xs.map((c, v) => ({ v, n: c })).filter((h) => h.n > 0); + let test: { name: "Mann–Whitney U" | "Kruskal–Wallis"; scoreP: number; fgaP: number } | null = null; + if (drawn.length === 2) test = { name: "Mann–Whitney U", scoreP: mannWhitneyU(hist(drawn[0].score), hist(drawn[1].score)).p, fgaP: mannWhitneyU(hist(drawn[0].fga), hist(drawn[1].fga)).p }; + else if (drawn.length > 2) test = { name: "Kruskal–Wallis", scoreP: kruskalWallis(drawn.map((pn) => hist(pn.score))).p, fgaP: kruskalWallis(drawn.map((pn) => hist(pn.fga))).p }; + const multi = ctx.vStudies.length > 1; + return { + kind: "instability", cohorts: vNames, + instability: { build: "hg19", threshold, armShare, includeX, arms, panels, test, ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "From each sample's copy-number segments (hg19; " + (includeX ? "autosomes and X" : "autosomes only — X's log ratio mostly records sex") + "), computed per sample in the database: " + + "FGA is the share of the sample's segmented length whose |segment mean| is at or past " + threshold + " (log2), counted in 1-point bins; " + + "an arm (p and q split at hg19's centromeres, from UCSC's cytoBand; the acrocentric p arms of 13, 14, 15, 21 and 22 left out, as Taylor et al. 2018 did) is gained or lost when more than " + + Math.round(armShare * 100) + "% of its length is past ±" + threshold + ", and the aneuploidy score is how many of the " + nArms + " arms are. " + + panels.map((pn) => pn.cohort + ": " + plural(scored(pn), "sample") + " scored of " + pn.samples.toLocaleString("en-US") + " (" + pn.segmented.toLocaleString("en-US") + " with segments; " + + pn.allArms.toLocaleString("en-US") + " with every arm covered)").join("; ") + "; an arm segments cover for less than " + Math.round(armShare * 100) + "% of its length is not called, which lowers that sample's score. " + + "Boxes are the quartiles, whiskers the 5th and 95th percentiles, read from the histograms" + + (test ? "; " + test.name + " compares the cohorts' distributions (normal approximation with ties)" : "") + ". " + + "Every sample of each patient counts. Panel-derived segments are noisy and purity is not corrected — a low-purity tumour's changes sit near 0 and read as stable — so these are relative measures within one assay, not absolute ploidy." + + (multi ? " Pooled across " + ctx.vStudies.length + " studies, whose segmentations may differ." : "") + + (noSegments.length ? " No segments for these patients in: " + [...new Set(noSegments)].join(", ") + "." : "") + + (excluded.length ? " Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/molsubtype.ts b/lib/views/molsubtype.ts new file mode 100644 index 0000000..86278a1 --- /dev/null +++ b/lib/views/molsubtype.ts @@ -0,0 +1,196 @@ +// Molecular-subtype view: a published molecular classifier applied to each patient, as derived +// subtypes — their shares per cohort, by an attribute, and overall survival by subtype. One +// classifier so far (endometrial); another is a new CLASSIFIERS entry with its own SQL. +import type { ViewResult, MolsubtypeView, MolsubtypePanel, MolsubtypeCounts } from "../types.ts"; +import { molCountsSql, molSurvivalSql, molMsiAttributesSql, type MolEndometrialOpts, type MolMsiRule } from "../federation-views.ts"; +import { chiSquareTest, kaplanMeier, logRankK, type SurvRecord } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; + +/** + * Pathogenic POLE exonuclease-domain hotspots: the eleven of León-Castillo et al., "Interpretation of + * somatic POLE mutations in endometrial carcinoma", J Pathol 2020;250:323–335 (PMID 31829442) — + * P286R, S297F, F367S, V411L, L424I, P436R, M444K, A456P, S459F, M295R, D368Y — with the + * second substitutions at F367 and L424 that the same codons carry (F367V, L424V). + */ +export const POLE_HOTSPOTS = ["P286R", "S297F", "F367S", "F367V", "V411L", "L424I", "L424V", "P436R", "M444K", "A456P", "S459F", "M295R", "D368Y"]; + +interface Classifier { label: string; subtypes: string[]; citation: string } +const CLASSIFIERS: Record = { + endometrial: { + label: "Endometrial carcinoma molecular classification (TCGA 2013 / ProMisE / WHO 2020)", + subtypes: ["POLEmut", "MMRd", "p53abn", "NSMP"], + citation: "TCGA, Nature 2013; Talhouk et al., ProMisE, Br J Cancer 2015; WHO Classification of Female Genital Tumours, 5th ed. 2020", + }, +}; + +/** the MSI rule when none is given: a categorical call if the cohort has one, else a score */ +const MSI_DEFAULTS: (MolMsiRule & { label: string })[] = [ + { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"], label: "MSI_TYPE (Instable = MSI-high, Stable = stable)" }, + { attribute: "MSI_SCORE", threshold: 10, stableBelow: 3, label: "MSI_SCORE (MSIsensor; ≥ 10 MSI-high, < 3 stable)" }, + { attribute: "MSI_SENSOR_SCORE", threshold: 10, stableBelow: 3, label: "MSI_SENSOR_SCORE (MSIsensor; ≥ 10 MSI-high, < 3 stable)" }, +]; +const MOL_COHORT_LIMIT = 6, MOL_SURVIVAL_COHORTS = 3, KM_MIN = 10; + +const blank = (k: number): MolsubtypeCounts => ({ counts: new Array(k).fill(0), classified: 0, notClassifiable: 0, why: {} }); +function add(to: MolsubtypeCounts, subtypes: string[], cls: string, why: string, n: number) { + const i = subtypes.indexOf(cls); + if (i >= 0) { to.counts[i] += n; to.classified += n; return; } + to.notClassifiable += n; + to.why[why || "not classifiable"] = (to.why[why || "not classifiable"] || 0) + n; +} + +/** groups by size, the largest `top` kept and the rest pooled into one named Other. Pure. */ +export function molTopGroups(groups: Map, top: number, k: number): { groups: (MolsubtypeCounts & { label: string })[]; pooled?: string[] } { + const size = (g: MolsubtypeCounts) => g.classified + g.notClassifiable; + const sorted = [...groups].sort((a, b) => size(b[1]) - size(a[1]) || a[0].localeCompare(b[0])); + const kept = sorted.slice(0, top).map(([label, g]) => ({ label, ...g })); + const rest = sorted.slice(top); + if (!rest.length) return { groups: kept }; + const other = blank(k); + for (const [, g] of rest) { + g.counts.forEach((n, i) => { other.counts[i] += n; }); + other.classified += g.classified; other.notClassifiable += g.notClassifiable; + for (const [w, n] of Object.entries(g.why)) other.why[w] = (other.why[w] || 0) + n; + } + return { groups: [...kept, { label: "Other (" + rest.length + ")", ...other }], pooled: rest.map(([l]) => l) }; +} + +/** chi-squared over rows × subtypes of classified counts, leaving out empty rows and subtypes; null below two of either */ +function tableTest(rows: number[][]) { + const r = rows.filter((x) => x.some((n) => n > 0)); + const cols = r.length ? r[0].map((_, j) => j).filter((j) => r.some((x) => x[j] > 0)) : []; + return r.length >= 2 && cols.length >= 2 ? chiSquareTest(r.map((x) => cols.map((j) => x[j]))) : undefined; +} + +export async function runMolsubtype(view: MolsubtypeView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + const no = (e: string) => refuse("molsubtype", ctx, e); + const classifier = view.classifier == null ? "endometrial" : String(view.classifier); + const cl = CLASSIFIERS[classifier]; + if (!cl) return no("no classifier “" + classifier.slice(0, 40) + "”; the classifiers are " + Object.keys(CLASSIFIERS).map((k) => k + " (" + CLASSIFIERS[k].label + ")").join(", ")); + { const e = overLimit("a molecular-subtype view", MOL_COHORT_LIMIT, vNames.length); if (e) return no(e.replace("at most " + MOL_COHORT_LIMIT, "at most " + MOL_COHORT_LIMIT + " cohorts")); } + const p53 = view.p53 === "nonsynonymous" ? "nonsynonymous" : "driver"; + if (p53 === "driver" && !pin) return no("no OncoKB annotation pin is available here to call an abnormal TP53 mutation; set p53 \"nonsynonymous\""); + const by = view.by == null || view.by === "" ? null : String(view.by).trim(); + if (by && !ATTR_NAME.test(by)) return no("by is a clinical attribute name; got " + JSON.stringify(view.by).slice(0, 40)); + const top = view.top == null ? 8 : Math.floor(Number(view.top)); + if (!(top >= 1 && top <= 20)) return no("top is 1 to 20 groups"); + const m = view.msi; + if (m && m.attribute != null && !ATTR_NAME.test(String(m.attribute))) return no("msi.attribute is a clinical attribute name"); + if (m && m.threshold != null && !(Number.isFinite(Number(m.threshold)))) return no("msi.threshold is a number"); + if (m && m.stableBelow != null && !(Number(m.stableBelow) <= Number(m.threshold ?? Infinity))) return no("msi.stableBelow is a number at or below msi.threshold"); + if (!vNames.some((n) => cohorts[n].count)) return no("nothing to classify: " + (vNames.length === 1 ? "the cohort is" : "every cohort is") + " empty"); + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return no("no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + + // the MSI rule: as given, else the first default the cohorts carry; refused naming what they have + const msiAttrs = new Map(); + for (const st of ctx.vStudies.filter((s) => ok[s])) + for (const n of vNames.filter((x) => cohorts[x].studies.includes(st))) { + for (const r of await ok[st].viewAggregate("molsubtype:msi-attributes", doc.cohorts[n], (c, s, d) => molMsiAttributesSql(c, s, d))) { + const a = String(r.attribute), x = msiAttrs.get(a) || { patients: 0, nvalues: 0, examples: [] }; + x.patients += Number(r.patients); x.nvalues = Math.max(x.nvalues, Number(r.nvalues)); + x.examples = [...new Set([...x.examples, ...((r.examples as unknown as unknown[]) || []).map(String)])].slice(0, 8); + msiAttrs.set(a, x); + } + } + const have = [...msiAttrs].map(([a, x]) => a + " (" + plural(x.patients, "patient") + ": " + x.examples.slice(0, 5).join(", ") + (x.nvalues > 5 ? ", …" : "") + ")").join("; ") || "none"; + let msi: MolMsiRule & { label: string }; + if (m && (m.attribute || m.high || m.threshold != null)) { + const attribute = String(m.attribute || "MSI_TYPE"); + if (!msiAttrs.has(attribute)) return no("the cohorts have no " + attribute + "; their MSI attributes are " + have); + if (m.threshold != null) msi = { attribute, threshold: Number(m.threshold), stableBelow: m.stableBelow == null ? Number(m.threshold) : Number(m.stableBelow), + label: attribute + " (≥ " + Number(m.threshold) + " MSI-high, < " + (m.stableBelow == null ? Number(m.threshold) : Number(m.stableBelow)) + " stable)" }; + else { + const high = (m.high || ["Instable"]).map(String), stable = (m.stable || ["Stable"]).map(String); + msi = { attribute, high, stable, label: attribute + " (" + high.join(" / ") + " = MSI-high, " + stable.join(" / ") + " = stable)" }; + } + } else { + const d = MSI_DEFAULTS.find((x) => msiAttrs.has(x.attribute)); + if (!d) return no("the classifier needs an MSI call and the cohorts have none of " + MSI_DEFAULTS.map((x) => x.attribute).join(", ") + "; their MSI attributes are " + have + " — name one with msi"); + msi = d; + } + const opts: MolEndometrialOpts = { poleHotspots: POLE_HOTSPOTS, msi, p53Pin: p53 === "driver" ? pin : null, by }; + + const k = cl.subtypes.length; + const panels: MolsubtypePanel[] = []; + const survival: NonNullable = []; + const wantSurvival = view.survival !== false; + let noSurvival: string | undefined; + for (const [ci, n] of vNames.entries()) { + const panel: MolsubtypePanel = { cohort: n, patients: cohorts[n].count, ...blank(k) }; + const groups = new Map(); + const recs = cl.subtypes.map(() => new Map()); + for (const st of cohorts[n].studies.filter((s) => ok[s])) { + const [rows, surv] = await Promise.all([ + ok[st].viewAggregate("molsubtype:counts:" + n, doc.cohorts[n], (c, s, d) => molCountsSql(c, s, d, opts)), + wantSurvival && ci < MOL_SURVIVAL_COHORTS ? ok[st].viewAggregate("molsubtype:survival:" + n, doc.cohorts[n], (c, s, d) => molSurvivalSql(c, s, d, opts)) : Promise.resolve([])]); + for (const r of rows) { + const cls = String(r.cls), why = String(r.why), cnt = Number(r.n); + add(panel, cl.subtypes, cls, why, cnt); + if (by) { + const g = String(r.grp || "") || "No value recorded"; + let x = groups.get(g); if (!x) groups.set(g, x = blank(k)); + add(x, cl.subtypes, cls, why, cnt); + } + } + for (const r of surv) { + const i = cl.subtypes.indexOf(String(r.cls)); if (i < 0) continue; + const t = Number(r.t), event = Number(r.dead) ? 1 : 0, cnt = Number(r.n); + let a = recs[i].get(st); if (!a) recs[i].set(st, a = []); + for (let j = 0; j < cnt; j++) a.push({ t, event }); + } + } + if (by) { + const tg = molTopGroups(groups, top, k); + panel.groups = tg.groups; + if (tg.pooled) panel.pooled = tg.pooled; + const t = tableTest(tg.groups.filter((g) => g.label !== "No value recorded").map((g) => g.counts)); + if (t) panel.byTest = t; + } + panels.push(panel); + if (wantSurvival && ci < MOL_SURVIVAL_COHORTS) { + const all = recs.map((byStudy) => [...byStudy.values()].flat()); + if (!all.some((a) => a.length)) { noSurvival = "no classified patient has OS_MONTHS"; continue; } + const curves = cl.subtypes.map((subtype, i) => ({ subtype, ...kaplanMeier(all[i]) })).filter((c) => c.n > 0); + const thin = curves.filter((c) => c.n < KM_MIN); + let logrank = null, noTest: string | undefined; + if (curves.length < 2) noTest = "a log-rank test needs two subtypes with patients"; + else if (thin.length) noTest = "log-rank refused: " + thin.map((c) => c.subtype + " (n=" + c.n + ")").join(", ") + (thin.length === 1 ? " has" : " have") + " fewer than " + KM_MIN + " patients"; + else { + const studies = [...new Set(recs.flatMap((b) => [...b.keys()]))].sort(); + const keepIdx = cl.subtypes.map((_, i) => i).filter((i) => all[i].length > 0); + logrank = logRankK(studies.map((st) => ({ groups: keepIdx.map((i) => recs[i].get(st) || []) }))); + } + survival.push({ cohort: n, curves, logrank, ...(noTest ? { noTest } : {}) }); + } + } + const cohortTest = panels.length >= 2 ? tableTest(panels.map((pn) => pn.counts)) : undefined; + const multi = ctx.vStudies.length > 1; + const nc = panels.reduce((a, pn) => a + pn.notClassifiable, 0), ncWhy: Record = {}; + for (const pn of panels) for (const [w, c] of Object.entries(pn.why)) ncWhy[w] = (ncWhy[w] || 0) + c; + const p53Word = p53 === "driver" ? "a TP53 mutation the OncoKB store (" + pin + ") calls oncogenic or likely oncogenic" : "any protein-changing TP53 mutation (missense, truncating, splice, in-frame indel)"; + return { + kind: "molsubtype", cohorts: vNames, + molsubtype: { classifier, classifierLabel: cl.label, subtypes: cl.subtypes, msi: msi.label, p53: p53Word, poleHotspots: POLE_HOTSPOTS, by, panels, + ...(cohortTest ? { cohortTest } : {}), ...(wantSurvival ? { survival } : {}), ...(noSurvival && !survival.length ? { noSurvival } : {}), + ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: cl.label + " (" + cl.citation + "), applied per patient over all their samples, in this order, first match wins: " + + "POLE-ultramutated — a somatic POLE exonuclease-domain hotspot called in any sample (" + POLE_HOTSPOTS.join(", ") + "; León-Castillo et al. 2020); " + + "else MMR-deficient — MSI-high in any sample by " + msi.label + " (from the MSI call, not mismatch-repair immunohistochemistry, which the warehouse does not hold); " + + "else p53-abnormal — " + p53Word + " (a sequencing proxy for p53 immunohistochemistry); else NSMP. " + + "A patient is “not classifiable”, never put in a subtype by guess, when an input the hierarchy needs before reaching their subtype is missing: not sequenced, not profiled for POLE or TP53 (panel-aware), or no MSI-high or stable call (an indeterminate or unreported MSI result counts as none) — " + + (nc ? plural(nc, "patient") + " here (" + Object.entries(ncWhy).map(([w, c]) => w + " " + c.toLocaleString("en-US")).join(", ") + "), kept out of the shares, tests and curves. " : "none here. ") + + "Shares are of classified patients. " + + (by ? "By " + by + " per patient (“Multiple” when samples disagree; the " + top + " largest groups drawn, the rest pooled into a named Other), chi-squared over group × subtype per cohort, patients without a value left out of the test. " : "") + + (cohortTest ? "Between cohorts: chi-squared over cohort × subtype (" + cohortTest.df + " df). " : "") + + (wantSurvival ? "Overall survival by subtype from OS_MONTHS / OS_STATUS as the study records them (for " + (vNames.length > MOL_SURVIVAL_COHORTS ? "the first " + MOL_SURVIVAL_COHORTS + " cohorts" : "each cohort") + + "), Kaplan–Meier with a k-group log-rank" + (multi ? " stratified by study" : "") + "; OS is not corrected for left truncation (a patient sequenced years after diagnosis entered the study alive), and the subtypes differ in stage, histology and treatment, none adjusted for. " : "") + + "A panel-based call is not the clinical assay: POLE variants outside the hotspot list, MSI by IHC, and p53 IHC patterns are not seen. " + + (multi ? "Counts are summed across " + ctx.vStudies.length + " studies; overlap is not deduplicated. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/multistate.ts b/lib/views/multistate.ts new file mode 100644 index 0000000..d95e994 --- /dev/null +++ b/lib/views/multistate.ts @@ -0,0 +1,199 @@ +// Multistate view: the illness–death model (start → progression → death, start → death) per cohort +// on the timeline's clock — transition rates, Nelson–Aalen cumulative hazards, Aalen–Johansen state +// occupation and a Cox hazard ratio per transition against the first cohort. Each participant +// returns id-free (prevalent, t_progression, t_end, dead, n) counts. +import type { ViewResult, MultistateView, MultistateCohort, MultistateCox, MultistateRate, MultistateTransitionKey } from "../types.ts"; +import { multistateCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, SEQUENCING_EVENT } from "../federation-views.ts"; +import { illnessDeath, coxRegression, COX_MAX_ITERATIONS, Z_975, type IllnessDeathRecord, type CoxRow } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural } from "./shared.ts"; +import { progressionEvent, progressionError, progressionCondition, clockFailures, CLOCK_TOLERANCE } from "../timeline-endpoints.ts"; + +const MS_COHORT_LIMIT = 4; +/** curves are drawn while at least this many of a cohort's patients are still followed */ +const MS_MIN_FOLLOWED = 10; +/** a cohort with fewer events than this in a transition gets no hazard ratio for it */ +const MS_MIN_EVENTS = 5; +/** day 0 is called first sequencing when at least this share of patients has a Sequencing event on it */ +const MS_SEQ0_SHARE = 0.9; + +const KEYS: MultistateTransitionKey[] = ["01", "02", "12"]; +const rd = (x: number) => Math.round(x * 1e5) / 1e5; + +/** the last time at least `k` records are still followed (the k-th largest end of follow-up) */ +function followedTo(recs: IllnessDeathRecord[], k: number): number { + const te = recs.map((r) => r.te).sort((a, b) => b - a); + return te.length >= k ? te[k - 1] : 0; +} + +export async function runMultistate(view: MultistateView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + { const e = overLimit("a multistate view", MS_COHORT_LIMIT, vNames.length); if (e) return refuse("multistate", ctx, e.replace("at most " + MS_COHORT_LIMIT, "at most " + MS_COHORT_LIMIT + " cohorts")); } + const progression = progressionEvent(view.progression); + { const e = progressionError(progression); if (e) return refuse("multistate", ctx, e); } + const prevalentMode = view.prevalent === "exclude" ? "exclude" as const : "include" as const; + if (view.prevalent != null && view.prevalent !== "include" && view.prevalent !== "exclude") + return refuse("multistate", ctx, "prevalent is \"include\" (patients progressed by day 0 start in the progression state) or \"exclude\""); + const horizons = (view.horizons == null ? [12, 24, 60] : view.horizons).map(Number); + if (!horizons.length || horizons.length > 6 || horizons.some((h) => !(h > 0 && h <= 600))) + return refuse("multistate", ctx, "horizons are 1 to 6 month values between 0 and 600"); + let cutoffMonths: number | undefined; + if (view.cutoffMonths != null) { + const c = Number(view.cutoffMonths); + if (!(Number.isFinite(c) && c > 0 && c <= 1200)) return refuse("multistate", ctx, "cutoffMonths is a positive number of months (at most 1200)"); + cutoffMonths = c; + } + + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return refuse("multistate", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + const perSite = new Map>(); + const lateBy = new Map(); + const tallies = new Map(); + for (const n of vNames) { + const sites = new Map(); + perSite.set(n, sites); + const T = { cohortN: 0, noFollowup: 0, prevalentOut: 0, notAssessed: 0, afterEnd: 0, seq0: 0, late: 0, n: 0 }; + tallies.set(n, T); + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + T.cohortN += cohorts[n].perStudy[st]?.count || 0; + const rows = await ok[st].viewAggregate("multistate:" + n, doc.cohorts[n], (c, s, d) => multistateCountsSql(c, s, d, progression)); + const recs: IllnessDeathRecord[] = []; + let siteLate = 0, siteN = 0; + for (const r of rows) { + const cnt = Number(r.n); + if (String(r.kind) === "nofollowup") { T.noFollowup += cnt; continue; } + const prevalent = Number(r.prevalent) === 1; + if (prevalent && prevalentMode === "exclude") { T.prevalentOut += cnt; continue; } + siteN += cnt; + if (Number(r.late)) siteLate += cnt; + if (!Number(r.assessed)) T.notAssessed += cnt; + if (Number(r.afterend)) T.afterEnd += cnt; + if (Number(r.seq0)) T.seq0 += cnt; + let tp: number | null = Number(r.tp) > 0 ? Number(r.tp) : null, te = Number(r.te), dead = Number(r.dead) === 1; + if (cutoffMonths != null && te > cutoffMonths) { te = cutoffMonths; dead = false; if (tp != null && tp >= cutoffMonths) tp = null; } + for (let i = 0; i < cnt; i++) recs.push({ prevalent, tp, te, dead }); + } + T.late += siteLate; T.n += siteN; + const L = lateBy.get(st) || { late: 0, anchored: 0 }; + lateBy.set(st, { late: L.late + siteLate, anchored: L.anchored + siteN }); + sites.set(st, recs); + })); + } + const badClock = clockFailures(lateBy); + if (badClock.length) + return refuse("multistate", ctx, "the timeline and OS_MONTHS do not share a clock in " + badClock.map(([st, x]) => st + " (" + plural(x.late, "patient") + " of " + + x.anchored.toLocaleString("en-US") + " have timeline events more than " + CLOCK_SLACK_DAYS + " days after OS_MONTHS)").join(", ") + + ", so progression and death cannot be placed on one clock"); + const all = (n: string) => [...perSite.get(n)!.values()].flat(); + if (!vNames.some((n) => all(n).length)) + return refuse("multistate", ctx, "no patient of " + (vNames.length === 1 ? "“" + vNames[0] + "”" : "these cohorts") + " has follow-up (OS_MONTHS > 0 and OS_STATUS)" + + (prevalentMode === "exclude" ? " without a progression by day 0" : "")); + const assessedAny = vNames.some((n) => tallies.get(n)!.n > tallies.get(n)!.notAssessed); + const fits = new Map(vNames.map((n) => [n, illnessDeath(all(n))])); + if (!assessedAny || ![...fits.values()].some((f) => f.events["01"] + f.prevalent > 0)) { + const first = Object.keys(ok)[0]; + const vals = await ok[first].viewAggregate("multistate:progression-values", doc.cohorts[vNames[0]], (c, s, d) => labTestsSql(c, s, d, progression.eventType, progression.key)).catch(() => []); + return refuse("multistate", ctx, "no patient of " + (vNames.length === 1 ? "“" + vNames[0] + "”" : "these cohorts") + " has a " + progression.eventType + " event with " + progressionCondition(progression) + + (vals.length ? ". " + progression.key + " takes: " + vals.slice(0, 20).map((v) => String(v.test) + " (" + Number(v.patients).toLocaleString("en-US") + " patients)").join(", ") + : ". No " + progression.eventType + " event here carries " + progression.key + " — set progression.eventType and progression.key to where this study records progression")); + } + + const out: MultistateCohort[] = vNames.map((n) => { + const f = fits.get(n)!, recs = all(n), T = tallies.get(n)!; + const drawnTo = followedTo(recs, MS_MIN_FOLLOWED); + const pt = { "01": f.personTime.s0, "02": f.personTime.s0, "12": f.personTime.s1 }; + const rates: MultistateRate[] = KEYS.map((k) => { + const d = f.events[k], pm = pt[k], rate = pm > 0 ? 100 * d / pm : 0; + return { key: k, events: d, personMonths: Math.round(pm * 10) / 10, rate, + lo: d > 0 ? rate * Math.exp(-Z_975 / Math.sqrt(d)) : null, hi: d > 0 ? rate * Math.exp(Z_975 / Math.sqrt(d)) : null }; + }); + const occ = f.occupation.filter((q) => q.t <= drawnTo); + const at = (m: number) => { + const atRisk = recs.filter((r) => r.te >= m).length; + if (m > drawnTo) return { months: m, p0: null, p1: null, p2: null, atRisk }; + let q = f.occupation[0]; + for (const x of f.occupation) { if (x.t > m) break; q = x; } + return { months: m, p0: rd(q.p0), p1: rd(q.p1), p2: rd(q.p2), atRisk }; + }; + const cumhaz = Object.fromEntries(KEYS.map((k) => [k, f.cumhaz[k].filter((q) => q.t <= drawnTo).map((q) => { + const half = q.h > 0 ? Z_975 * Math.sqrt(q.v) / q.h : 0; + return { t: q.t, h: rd(q.h), lo: rd(q.h * Math.exp(-half)), hi: rd(q.h * Math.exp(half)) }; + })])) as MultistateCohort["cumhaz"]; + return { cohort: n, cohortPatients: T.cohortN, n: f.n, prevalent: f.prevalent, + excluded: { noFollowup: T.noFollowup, prevalent: T.prevalentOut, }, + notAssessed: T.notAssessed, afterEnd: T.afterEnd, seq0: T.seq0, late: T.late, + censored: { start: f.events.c0, progression: f.events.c1 }, rates, + occupation: occ.map((q) => ({ t: q.t, p0: rd(q.p0), p1: rd(q.p1), p2: rd(q.p2) })), + cumhaz, drawnTo, atHorizon: horizons.map(at) }; + }); + + // a Cox model per transition: the cohorts as indicators against the first, a baseline per study; + // progression → death on the same clock (time since day 0), entering at the progression + const cox: MultistateCox[] = []; + const reference = vNames[0]; + if (vNames.length >= 2) { + for (const k of KEYS) { + const rows: CoxRow[] = []; + vNames.forEach((n, ci) => { + const x = vNames.slice(1).map((_, j) => (ci === j + 1 ? 1 : 0)); + for (const [st, recs] of perSite.get(n)!) for (const r of recs) { + if (k === "12") { + if (!(r.prevalent || r.tp != null)) continue; + const entry = r.prevalent ? 0 : r.tp!; + if (r.te <= entry) continue; + rows.push(entry > 0 ? { t: r.te, event: r.dead ? 1 : 0, x, stratum: st, entry } : { t: r.te, event: r.dead ? 1 : 0, x, stratum: st }); + } else if (!r.prevalent) { + const ev = k === "01" ? r.tp != null : r.tp == null && r.dead; + rows.push({ t: r.tp ?? r.te, event: ev ? 1 : 0, x, stratum: st }); + } + } + }); + const events = rows.reduce((a, r) => a + r.event, 0); + const thin = out.filter((c) => c.rates.find((q) => q.key === k)!.events < MS_MIN_EVENTS).map((c) => c.cohort); + if (thin.length) { cox.push({ key: k, rows: [], n: rows.length, events, noFit: thin.join(", ") + (thin.length === 1 ? " has" : " have") + " fewer than " + MS_MIN_EVENTS + " events" }); continue; } + try { + const fit = coxRegression(rows); + if (fit.iterations >= COX_MAX_ITERATIONS) { cox.push({ key: k, rows: [], n: rows.length, events, noFit: "the fit did not converge" }); continue; } + cox.push({ key: k, n: rows.length, events, rows: vNames.slice(1).map((n, j) => ({ cohort: n, hr: fit.hr[j], lo: fit.ciLow[j], hi: fit.ciHigh[j], p: fit.p[j] })) }); + } catch (e) { + cox.push({ key: k, rows: [], n: rows.length, events, noFit: (e as Error).message }); + } + } + } + + const inModel = out.reduce((a, c) => a + c.n, 0), seq0 = out.reduce((a, c) => a + c.seq0, 0); + const origin = inModel && seq0 / inModel >= MS_SEQ0_SHARE ? "first sequencing" : "the timeline's day 0"; + const tMax = Math.max(1, ...out.map((c) => c.drawnTo)); + const sum = (f: (c: MultistateCohort) => number) => out.reduce((a, c) => a + f(c), 0); + const multi = ctx.vStudies.length > 1; + const lateTotal = sum((c) => c.late); + return { + kind: "multistate", cohorts: vNames, + multistate: { progression, prevalent: prevalentMode, horizons, ...(cutoffMonths != null ? { cutoffMonths } : {}), tMax, origin, + cohorts: out, reference, cox, ...(excluded.length ? { excludedStudies: excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "An illness–death model (Rueda et al., Nature 2019) on one clock: months from the timeline's day 0, which is " + + (origin === "first sequencing" ? "the day of a " + SEQUENCING_EVENT + " event for " + plural(seq0, "patient") + " of " + inModel.toLocaleString("en-US") + " (day 0 = first sequencing), so the start state is “sequenced, not yet progressed”. " + : "a " + SEQUENCING_EVENT + " day for only " + plural(seq0, "patient") + " of " + inModel.toLocaleString("en-US") + ": the start state is “at the study's day 0, not yet progressed”, and patients sequenced later had to survive to it, so early death is understated. ") + + "Progression is the first " + progression.eventType + " event with " + progressionCondition(progression) + " after day 0; death and the end of follow-up are OS_STATUS / OS_MONTHS (OS_MONTHS × " + DAYS_PER_MONTH + + " days on the timeline's clock; checked: " + plural(lateTotal, "patient") + " of " + inModel.toLocaleString("en-US") + " with a timeline event more than " + CLOCK_SLACK_DAYS + + " days past it, a study above " + Math.round(CLOCK_TOLERANCE * 100) + "% is refused). " + + (prevalentMode === "include" + ? sum((c) => c.prevalent).toLocaleString("en-US") + " patients had a progression on or before day 0 and start in the progression state. " + : sum((c) => c.excluded.prevalent).toLocaleString("en-US") + " patients with a progression on or before day 0 are left out. ") + + "Markov, clock-forward: every transition's hazard is a function of time since day 0, progression → death included (a patient enters that risk set at their progression), so time already spent progressed does not enter the model. " + + "Rates are events per 100 person-months in the state left (95% interval, Poisson); cumulative hazards Nelson–Aalen (95% interval, log scale); state occupation Aalen–Johansen from the transitions, drawn without intervals while at least " + + MS_MIN_FOLLOWED + " of a cohort's patients are followed. " + + (cox.length ? "Hazard ratios: one Cox model per transition (Efron ties), each cohort against " + reference + (multi ? ", a baseline hazard per study" : "") + "; a cohort with fewer than " + MS_MIN_EVENTS + " events in a transition gets none. " : "") + + "Progression here is a reading of the record: in MSK-CHORD an NLP model's call on each radiology report, so it is probabilistic and only as dense as the imaging — " + + plural(sum((c) => c.notAssessed), "patient") + " in the model have no " + progression.key + " record at all and could never be seen to progress, and their deaths count as death without progression. " + + (sum((c) => c.afterEnd) ? sum((c) => c.afterEnd).toLocaleString("en-US") + " with a progression only on or after their end of follow-up count as not progressed. " : "") + + "Left out: " + sum((c) => c.excluded.noFollowup).toLocaleString("en-US") + " without follow-up. " + + (cutoffMonths != null ? "Administratively censored at " + cutoffMonths + " months. " : "") + + "The cohorts are not randomised, so differences are associations. " + + (multi ? "Records are pooled across " + ctx.vStudies.length + " studies, whose day 0 and recording need not match. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + ". " : "") + + "Each site sends only (prevalent, progression time, end time, dead) counts.", + }; +} diff --git a/lib/views/organotropism.ts b/lib/views/organotropism.ts new file mode 100644 index 0000000..a5b74fd --- /dev/null +++ b/lib/views/organotropism.ts @@ -0,0 +1,239 @@ +// Organotropism view: for each organ a cohort's patients metastasised to and each gene, whether the +// gene's alteration goes with metastasis there within cancer types (Cochran–Mantel–Haenszel), and, +// from dated timeline sites, the cumulative incidence of one site by one gene's status. +import type { ViewResult, OrganotropismView, OrganotropismCell, OrganotropismSite, OrganotropismIncidence, IncidenceCurve } from "../types.ts"; +import { + orgSourcesSql, orgSitesSql, orgOtherValuesSql, orgTopGenesSql, orgCountsSql, orgIncidenceSql, + type OrgSource, type OrgSourceKind, type EnrichAlteration, +} from "../federation-views.ts"; +import { cochranMantelHaenszel, benjaminiHochberg, aalenJohansen, cifAt, grayPermutationTest, Z_975, type CrRecord } from "../stats.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural, ATTR_NAME } from "./shared.ts"; + +const ORG_MAX_GENES = 20, ORG_MAX_SITES = 30; +const ORG_DEFAULTS = { prefix: "DMETS_DX_", eventType: "Diagnosis", key: "TUMOR_SITE", attribute: "METASTATIC_SITE" }; +const WORD: Partial> = { mutation: "mutation", cna: "amplification or deep deletion", amp: "amplification", homdel: "deep deletion", sv: "structural variant" }; +const HORIZONS = [12, 24, 60]; +/** a study whose share of patients with a site event after their last follow-up passes this has no usable clock */ +const CLOCK_TOLERANCE = 0.05; +const MIN_ARM = 10; + +/** a flag attribute's suffix as a label: DMETS_DX_CNS_BRAIN → "CNS Brain" */ +export const flagLabel = (s: string) => s.split("_").filter(Boolean).map((w) => w.length <= 3 ? w.toUpperCase() : w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()).join(" "); + +interface OrgStratumCounts { n: number; withSite: number; altered: number; both: number } + +/** One (site, gene) cell from its per-stratum margins: the pooled counts, and the CMH test and + * Mantel–Haenszel odds ratio when it has enough patients (`min` altered and `min` with the site). Pure. */ +export function orgCell(site: string, gene: string, strata: OrgStratumCounts[], min: number): OrganotropismCell { + let withSite = 0, withAltered = 0, n = 0, altered = 0; + const tables = strata.map((s) => { + withSite += s.withSite; withAltered += s.both; n += s.n; altered += s.altered; + // a: altered with the site, b: altered without, c: not altered with, d: not altered without + return { a: s.both, b: s.altered - s.both, c: s.withSite - s.both, d: s.n - s.altered - s.withSite + s.both }; + }); + const cell: OrganotropismCell = { site, gene, withSite, withAltered, without: n - withSite, withoutAltered: altered - withAltered, or: null, log2Or: null, p: null, q: null, strata: 0 }; + if (altered < min || withSite < min || n - withSite < min) return cell; + const t = cochranMantelHaenszel(tables); + if (!t.tables) return cell; + return { ...cell, or: t.oddsRatio, log2Or: t.oddsRatio != null && t.oddsRatio > 0 ? Math.round(Math.log2(t.oddsRatio) * 1e3) / 1e3 : null, p: t.p, strata: t.tables }; +} + +const SOURCE_WORD: Record string> = { + flags: (s) => "per-organ flags " + s.prefix + " (Yes / No)", + timeline: (s) => "timeline " + s.eventType + " events' " + s.key, + sample: (s) => "the biopsy site of a sample, " + s.attribute + ", mapped to organs", +}; + +export async function runOrganotropism(view: OrganotropismView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames, pin } = ctx; + const no = (e: string) => refuse("organotropism", ctx, e); + if (vNames.length !== 1) return no("an organotropism view reads one cohort; this one draws " + vNames.length); + const asked = view.source == null ? "auto" : String(view.source); + if (!["auto", "flags", "timeline", "sample"].includes(asked)) return no("source is auto, flags, timeline or sample; got " + JSON.stringify(view.source).slice(0, 30)); + const prefix = view.flagPrefix == null ? ORG_DEFAULTS.prefix : String(view.flagPrefix).trim(); + const attribute = view.siteAttribute == null ? ORG_DEFAULTS.attribute : String(view.siteAttribute).trim(); + if (!ATTR_NAME.test(prefix)) return no("flagPrefix is an attribute-name prefix; got " + JSON.stringify(view.flagPrefix).slice(0, 40)); + if (!ATTR_NAME.test(attribute)) return no("siteAttribute is an attribute name; got " + JSON.stringify(view.siteAttribute).slice(0, 40)); + const eventType = String(view.timeline?.eventType || ORG_DEFAULTS.eventType).trim().slice(0, 120); + const key = String(view.timeline?.key || ORG_DEFAULTS.key).trim().slice(0, 120); + const stratifyBy = view.stratifyBy === null ? null : view.stratifyBy == null || view.stratifyBy === "" ? "CANCER_TYPE" : String(view.stratifyBy).trim(); + if (stratifyBy && !ATTR_NAME.test(stratifyBy)) return no("stratifyBy is a clinical attribute name, or null; got " + JSON.stringify(view.stratifyBy).slice(0, 40)); + const alteration = (view.alteration || "mutation") as EnrichAlteration; + if (!WORD[alteration]) return no("alteration is mutation, cna, amp, homdel or sv; got " + JSON.stringify(view.alteration).slice(0, 30)); + const measure: "any" | "driver" = view.measure === "any" ? "any" : "driver"; + if (measure === "driver" && !pin) return no("no OncoKB annotation pin is available here, so drivers cannot be called; set measure \"any\""); + const min = view.minPatients == null ? 5 : Math.floor(Number(view.minPatients)); + if (!(min >= 1 && min <= 10000)) return no("minPatients is 1 to 10000"); + const top = view.top == null ? 12 : Math.floor(Number(view.top)); + if (!(top >= 1 && top <= ORG_MAX_GENES)) return no("top is 1 to " + ORG_MAX_GENES + " genes"); + const askedGenes = Array.isArray(view.genes) && view.genes.length ? [...new Set(view.genes.map((g) => String(g).trim().toUpperCase()).filter(Boolean))] : null; + if (askedGenes) { + const e = overLimit("an organotropism view", ORG_MAX_GENES, askedGenes.length); if (e) return no(e.replace("at most " + ORG_MAX_GENES, "at most " + ORG_MAX_GENES + " genes")); + const bad = askedGenes.find((g) => !/^[A-Z0-9_.-]{1,40}$/.test(g)); if (bad) return no("'" + bad.slice(0, 40) + "' is not a gene symbol"); + } + const n = vNames[0]; + if (!cohorts[n].count) return no("nothing to read: cohort “" + n + "” is empty"); + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return no("no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + const def = doc.cohorts[n]; + const studies = cohorts[n].studies.filter((s) => ok[s]); + + // which source the cohort carries + const found = { flag_patients: 0, flags: 0, timeline_patients: 0, sample_patients: 0 }; + for (const st of studies) { + const [r] = await ok[st].viewAggregate("organotropism:sources", def, (c, s, d) => orgSourcesSql(c, s, d, { prefix, eventType, key, attribute })); + if (r) for (const k of Object.keys(found) as (keyof typeof found)[]) found[k] += Number(r[k]) || 0; + } + const have: Record = { flags: found.flags ? found.flag_patients : 0, timeline: found.timeline_patients, sample: found.sample_patients }; + const kind: OrgSourceKind | undefined = asked === "auto" ? (["flags", "timeline", "sample"] as const).find((k) => have[k] > 0) : asked as OrgSourceKind; + const listing = plural(have.flags, "patient") + " with " + prefix + "* flags (" + plural(found.flags, "flag") + "), " + + plural(have.timeline, "patient") + " with " + eventType + " / " + key + " on the timeline, " + plural(have.sample, "patient") + " with " + attribute; + if (!kind || !have[kind]) return no("“" + n + "” has no metastatic sites from " + (kind ? SOURCE_WORD[kind]({ kind, prefix, eventType, key, attribute }) : "any source") + ": it has " + listing); + const src: OrgSource = kind === "flags" ? { kind, prefix } : kind === "timeline" ? { kind, eventType, key } : { kind, attribute }; + + // the sites + const siteMap = new Map(); + const otherValues = new Map(); + for (const st of studies) { + const [rows, other] = await Promise.all([ + ok[st].viewAggregate("organotropism:sites", def, (c, s, d) => orgSitesSql(c, s, d, src)), + kind === "sample" ? ok[st].viewAggregate("organotropism:other", def, (c, s, d) => orgOtherValuesSql(c, s, d, attribute)) : Promise.resolve([])]); + for (const r of rows) { + const site = String(r.site); + const x = siteMap.get(site) || { site, label: kind === "flags" ? flagLabel(site) : String(r.label), evaluable: 0, withSite: 0 }; + x.evaluable += Number(r.evaluable); x.withSite += Number(r.with_site); + siteMap.set(site, x); + } + for (const r of other) otherValues.set(String(r.value), (otherValues.get(String(r.value)) || 0) + Number(r.patients)); + } + const allSites = [...siteMap.values()].sort((a, b) => b.withSite - a.withSite || a.label.localeCompare(b.label)); + let sites: OrganotropismSite[]; + if (Array.isArray(view.sites) && view.sites.length) { + const want = view.sites.map((s) => String(s).trim().toLowerCase()); + const missing = want.filter((w) => !allSites.some((s) => s.label.toLowerCase() === w || s.site.toLowerCase() === w)); + if (missing.length) return no("no site " + missing.map((m) => "“" + m + "”").join(", ") + " in “" + n + "”; its sites are " + allSites.map((s) => s.label + " (" + s.withSite + ")").join(", ")); + sites = allSites.filter((s) => want.includes(s.label.toLowerCase()) || want.includes(s.site.toLowerCase())); + } else sites = allSites.filter((s) => s.withSite >= min); + { const e = overLimit("an organotropism view", ORG_MAX_SITES, sites.length); if (e) return no(e.replace("at most " + ORG_MAX_SITES, "at most " + ORG_MAX_SITES + " sites") + "; choose them with sites"); } + if (!sites.length) return no("no site has " + min + " or more patients with a metastasis there in “" + n + "”: " + allSites.map((s) => s.label + " (" + s.withSite + ")").join(", ")); + + // the genes + const driverPin = measure === "driver" ? pin : null; + let genes: { gene: string; altered?: number }[]; + if (askedGenes) genes = askedGenes.map((gene) => ({ gene })); + else { + const tally = new Map(); + for (const st of studies) for (const r of await ok[st].viewAggregate("organotropism:top", def, (c, s, d) => orgTopGenesSql(c, s, d, { alteration, driverPin }, top))) + tally.set(String(r.gene), (tally.get(String(r.gene)) || 0) + Number(r.altered)); + genes = [...tally].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, top).map(([gene, altered]) => ({ gene, altered })); + if (!genes.length) return no("no patient of “" + n + "” has a " + WORD[alteration] + (driverPin ? " called an OncoKB driver" : "") + " on a covering panel"); + } + const geneList = genes.map((g) => g.gene); + + // the counts, per (site, gene, stratum) + const strata = new Map(); + const strataSeen = new Set(); + for (const st of studies) { + const rows = await ok[st].viewAggregate("organotropism:counts", def, (c, s, d) => orgCountsSql(c, s, d, src, { genes: geneList, alteration, driverPin }, stratifyBy)); + for (const r of rows) { + const k = String(r.site) + "\u0000" + String(r.gene); + let a = strata.get(k); if (!a) strata.set(k, a = []); + // strata are kept per study too: a study's cancer types are its own vocabulary + strataSeen.add(st + "\u0000" + String(r.stratum)); + a.push({ n: Number(r.n), withSite: Number(r.with_site), altered: Number(r.altered), both: Number(r.both) }); + } + } + const cells: OrganotropismCell[] = []; + for (const s of sites) for (const g of geneList) cells.push(orgCell(s.site, g, strata.get(s.site + "\u0000" + g) || [], min)); + const tested = cells.filter((c) => c.p != null); + benjaminiHochberg(tested.map((c) => c.p!)).forEach((q, i) => { tested[i].q = q; }); + const significant = tested.filter((c) => c.q! < 0.05).length; + + // the cumulative incidence of one site by one gene's status, from dated timeline sites + let incidence: OrganotropismIncidence | undefined, noIncidence: string | undefined; + if (kind === "timeline" && view.incidence !== null) { + const best = [...tested].sort((a, b) => a.p! - b.p! || a.site.localeCompare(b.site))[0]; + const wantSite = view.incidence?.site ? String(view.incidence.site).trim().toLowerCase() : null; + const site = wantSite ? allSites.find((s) => s.label.toLowerCase() === wantSite || s.site === wantSite) : sites.find((s) => s.site === best?.site); + const gene = view.incidence?.gene ? String(view.incidence.gene).trim().toUpperCase() : best?.gene; + if (wantSite && !site) return no("incidence.site “" + wantSite + "” is not a site of “" + n + "”; its sites are " + allSites.map((s) => s.label).join(", ")); + if (gene && !/^[A-Z0-9_.-]{1,40}$/.test(gene)) return no("incidence.gene '" + gene.slice(0, 40) + "' is not a gene symbol"); + if (!site || !gene) noIncidence = "no cell was tested, so there is no site and gene to follow over time"; + else { + const arms = [gene + " altered", gene + " not altered"]; + const recs: CrRecord[][] = [[], []]; + const curves: IncidenceCurve[] = []; + let late = 0, records = 0; + for (const [i, altered] of [true, false].entries()) { + const ex = { prior: 0, unobserved: 0, noFollowup: 0, noAnchor: 0 }; + for (const st of studies) { + const rows = await ok[st].viewAggregate("organotropism:incidence:" + (altered ? "altered" : "wildtype"), def, + (c, s, d) => orgIncidenceSql(c, s, d, src, { genes: [gene], alteration, driverPin }, altered, site.label)); + for (const r of rows) { + const k = String(r.kind), cnt = Number(r.n); + if (k === "prior") { ex.prior += cnt; continue; } + if (k === "unobserved") { ex.unobserved += cnt; continue; } + if (k === "nofollowup") { ex.noFollowup += cnt; continue; } + records += cnt; + if (Number(r.late)) late += cnt; + const entry = Number(r.entry), t = Number(r.t), cause = Number(r.cause) as 0 | 1 | 2; + for (let j = 0; j < cnt; j++) recs[i].push(entry > 0 ? { t, cause, entry } : { t, cause }); + } + } + const entryKey = (r: CrRecord) => r.entry ?? -Infinity; + recs[i].sort((x, y) => x.t - y.t || x.cause - y.cause || (entryKey(x) < entryKey(y) ? -1 : entryKey(x) > entryKey(y) ? 1 : 0)); + const c = aalenJohansen(recs[i]); + curves.push({ cohort: arms[i], n: c.n, events: c.events, competing: c.competing, censored: c.censored, excluded: ex, late: 0, + points: c.points.map((pt) => ({ t: pt.t, f1: Math.round(pt.f1 * 1e5) / 1e5, f2: Math.round(pt.f2 * 1e5) / 1e5, se1: Math.round(pt.se1 * 1e5) / 1e5, atRisk: pt.atRisk })) }); + } + if (records && late / records > CLOCK_TOLERANCE) + noIncidence = plural(late, "patient") + " of " + records.toLocaleString("en-US") + " have their first " + site.label + " event after their last follow-up (OS_MONTHS), so the timeline and OS_MONTHS do not share a clock here and the incidence is not drawn"; + else if (curves.some((c) => c.n < MIN_ARM)) + noIncidence = "fewer than " + MIN_ARM + " patients in a group (" + curves.map((c) => c.cohort + " " + c.n).join(", ") + ") for the incidence of " + site.label; + else { + const perms = records > 50000 ? 500 : 2000; + incidence = { site: site.label, gene, curves, late, records, gray: grayPermutationTest(recs, perms, 1), + horizons: HORIZONS.map((months) => ({ months, cells: curves.map((c, i) => { + const at = cifAt(aalenJohansen(recs[i]), months); + return { cohort: c.cohort, f1: at.f1, lo: Math.max(0, at.f1 - Z_975 * at.se1), hi: Math.min(1, at.f1 + Z_975 * at.se1), + atRisk: recs[i].filter((r) => (r.entry ?? -Infinity) < months && r.t >= months).length }; + }) })) }; + } + } + } + + const word = WORD[alteration]!; + const multi = ctx.vStudies.length > 1; + const sourceLabel = SOURCE_WORD[kind](src); + const nStrata = strataSeen.size; + const other = [...otherValues].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).map(([value, patients]) => ({ value, patients })); + const sourceWords = kind === "flags" + ? "Sites are the per-organ flags " + prefix + ": a patient has a metastasis to a site when any of their records says Yes, and is evaluable for it when the flag is recorded Yes or No. " + : kind === "timeline" + ? "Sites are the values of " + key + " on " + eventType + " timeline events, at any date: a patient with any site recorded is evaluable for every site, and has a site when an event names it. The timeline's tumour sites include the primary tumour's own organ, so the column for the organ a cancer type starts in mostly records primaries. " + : "Sites are the biopsy sites of the cohort's samples (" + attribute + "), free text mapped to organs by this view's own rules (lib/federation-views.ts ORG_SITE_MAP); values matching no rule are counted as Other" + + (other.length ? " — here " + other.slice(0, 12).map((o) => "“" + o.value + "” (" + o.patients + ")").join(", ") + (other.length > 12 ? " and " + (other.length - 12) + " more" : "") : "") + + ". Every cohort patient is evaluable; a site is one a metastasis was sampled from, not every site a patient's cancer reached, so “without” includes patients whose metastases there were never biopsied. "; + return { + kind: "organotropism", cohorts: vNames, + organotropism: { source: kind, sourceLabel, alteration: word, measure, stratifyBy, minPatients: min, patients: cohorts[n].count, + sites, genes, cells, tested: tested.length, significant, + ...(kind === "sample" ? { otherValues: other } : {}), ...(incidence ? { incidence } : {}), ...(noIncidence ? { noIncidence } : {}), + ...(excluded.length ? { excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: sourceWords + + "Per site and gene, the patients evaluable for the site and profiled for the gene (a " + (alteration === "mutation" ? "mutation" : alteration === "sv" ? "structural-variant" : "copy-number") + + " panel listing it, or whole-exome; a patient not profiled is in neither group, never wild-type) are split by metastasis there and by a " + word + (driverPin ? " the OncoKB store (" + driverPin + ") calls oncogenic or likely oncogenic" : "") + + " in the gene called on a covering panel, in any of their samples. " + + (stratifyBy ? "The 2×2 tables are stratified by " + stratifyBy + " per patient (“Multiple” when samples disagree, a stratum of its own; " + plural(nStrata, "stratum", "strata") + (multi ? ", kept per study" : "") + "), and tested by the Cochran–Mantel–Haenszel test (continuity-corrected) with the Mantel–Haenszel common odds ratio — so a gene is compared with metastasis WITHIN cancer types, and a cancer type that both carries the gene and spreads to the site does not make the association by itself. " : "The 2×2 tables are not stratified (stratifyBy null) — a gene common in a cancer type that often spreads to a site will look associated with it; the Cochran–Mantel–Haenszel test is run on the one table. ") + + "A cell is tested with at least " + min + " altered patients, " + min + " with the site and " + min + " without; Benjamini–Hochberg q over the " + plural(tested.length, "cell") + " tested. The colour is log2 of the odds ratio. " + + "A site is only recorded where imaging, biopsy or the source's own abstraction found it, so detection follows practice (who was scanned, how often, which sites are biopsied); patients sequenced from a metastasis are more likely to have that site recorded. Associations are not adjusted for stage, time followed, treatment or tumour mutation burden. " + + (incidence ? "The lower panel: cumulative incidence of the first " + incidence.site + " event after the timeline's day 0 for patients profiled for " + incidence.gene + ", altered against not, death (OS_STATUS) competing, Aalen–Johansen with delta-method 95% intervals; patients with the site at or before day 0 are excluded rather than counted as free of it (" + + incidence.curves.map((c) => c.cohort + " " + c.excluded.prior).join(", ") + "), as are those without follow-up. Censoring at OS_MONTHS on the timeline's clock; " + plural(incidence.late, "patient") + " of " + incidence.records.toLocaleString("en-US") + + " have the event after the last follow-up (refused above " + Math.round(CLOCK_TOLERANCE * 100) + "%). Gray's test by permutation (" + incidence.gray!.permutations + " shuffles, seeded). " : noIncidence ? "No incidence panel: " + noIncidence + ". " : "") + + (multi ? "Counts are summed across " + ctx.vStudies.length + " studies; overlap is not deduplicated and site vocabularies are not harmonised. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + "." : "") + }; +} diff --git a/lib/views/predictive.ts b/lib/views/predictive.ts new file mode 100644 index 0000000..f1b27dc --- /dev/null +++ b/lib/views/predictive.ts @@ -0,0 +1,145 @@ +// Predictive biomarker view: for each gene, a Cox model of survival on the gene, a treatment +// exposure and their interaction — does the gene's prognosis differ between the treated and the +// untreated? Fitted from per-patient rows inside each local patient-level study; a patient not +// profiled for a gene is left out of that gene's fit, never counted as wild-type. +import type { ViewResult, PredictiveView, PredictiveGene, PredictiveEffect, CoxCovariate } from "../types.ts"; +import { hasViewAggregates, isPatientLevel } from "../federation.ts"; +import { riskmodelRowsSql, riskmodelPanelGenesSql } from "../federation-views.ts"; +import { coxRegression, benjaminiHochberg, type CoxRow, type CoxFit } from "../stats.ts"; +import type { AxisKind } from "../plot.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, refuse, plural, ATTR_NAME } from "./shared.ts"; +import { buildCoxColumns } from "./covariates.ts"; + +const PRED_MAX_GENES = 20, PRED_MAX_COVARIATES = 6; + +interface PredRow { t: number; ev: number; exposed: boolean; genes: Set; panels: string[]; cov: string[]; stratum: string; study: string } + +/** effect k of a fit, as a hazard ratio with its interval and p */ +const effect = (f: CoxFit, k: number): PredictiveEffect => ({ hr: f.hr[k], ciLow: f.ciLow[k], ciHigh: f.ciHigh[k], p: f.p[k] }); + +/** + * One gene's fits over the rows profiled for it. The model is gene + exposed + gene×exposed (+ + * covariates); it is fitted twice, coded two ways — the interaction as gene×exposed, then as + * gene×unexposed — so the gene's own coefficient is first its effect among the unexposed, then + * among the exposed. The two codings are the same model (same likelihood), so both contrasts and + * the interaction come from one fit's estimates and standard errors. + */ +export function fitPredictiveGene(rows: { t: number; ev: number; exposed: boolean; mutated: boolean; x: number[]; stratum?: string }[]) { + const coded = (inter: (r: typeof rows[number]) => number): CoxRow[] => rows.map((r) => ({ t: r.t, event: r.ev, + x: [r.mutated ? 1 : 0, r.exposed ? 1 : 0, (r.mutated ? 1 : 0) * inter(r), ...r.x], ...(r.stratum ? { stratum: r.stratum } : {}) })); + const u = coxRegression(coded((r) => r.exposed ? 1 : 0)); + const e = coxRegression(coded((r) => r.exposed ? 0 : 1)); + return { inUnexposed: effect(u, 0), inExposed: effect(e, 0), interaction: effect(u, 2), n: u.n, events: u.events, converged: u.iterations > 0 && e.iterations > 0 }; +} + +export async function runPredictive(view: PredictiveView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + if (vNames.length < 1 || vNames.length > 2) return refuse("predictive", ctx, "a predictive-biomarker view reads one or two cohorts' patients, pooled; this names " + vNames.length); + const ex0 = view.exposure; + if (!ex0 || typeof ex0 !== "object" || !String(ex0.eventType || "").trim()) + return refuse("predictive", ctx, "a predictive-biomarker view needs exposure: {eventType, key?, value?} — e.g. {eventType: \"Treatment\", key: \"SUBTYPE\", value: \"Immuno\"}"); + if (!!ex0.key !== (ex0.value != null && ex0.value !== "")) return refuse("predictive", ctx, "exposure's key and value go together"); + const exposure = { eventType: String(ex0.eventType).trim().slice(0, 120), ...(ex0.key ? { key: String(ex0.key).slice(0, 120), value: String(ex0.value).slice(0, 200) } : {}), occurrence: "FIRST" as const }; + const exposureLabel = exposure.eventType + (exposure.key ? " [" + exposure.key + "=" + exposure.value + "]" : ""); + const genes = [...new Set((Array.isArray(view.genes) ? view.genes : []).map((g) => String(g).trim().toUpperCase()).filter(Boolean))]; + if (!genes.length) return refuse("predictive", ctx, "a predictive-biomarker view needs genes, the candidate biomarkers (at most " + PRED_MAX_GENES + ")"); + { const e = overLimit("a predictive-biomarker view", PRED_MAX_GENES, genes.length); if (e) return refuse("predictive", ctx, e.replace("at most " + PRED_MAX_GENES, "at most " + PRED_MAX_GENES + " genes")); } + for (const g of genes) if (!/^[A-Z0-9][A-Z0-9_.-]{0,39}$/.test(g)) return refuse("predictive", ctx, "“" + g.slice(0, 40) + "” is not a gene symbol"); + const endpoint = String(view.endpoint || "OS").toUpperCase().trim(); + if (!/^[A-Z][A-Z0-9_]{0,39}$/.test(endpoint)) return refuse("predictive", ctx, "endpoint is a name like OS or PFS"); + if (!!view.monthsAttribute !== !!view.statusAttribute) return refuse("predictive", ctx, "monthsAttribute and statusAttribute go together"); + for (const a of [view.monthsAttribute, view.statusAttribute]) if (a != null && !ATTR_NAME.test(String(a))) return refuse("predictive", ctx, "“" + String(a).slice(0, 40) + "” is not an attribute name"); + const columns = view.monthsAttribute ? { months: String(view.monthsAttribute), status: String(view.statusAttribute) } : undefined; + const endpointLabel = columns ? columns.months + "/" + columns.status : endpoint; + const measure = view.measure === "driver" ? "driver" as const : "any" as const; + if (measure === "driver" && !ctx.pin) return refuse("predictive", ctx, "measure \"driver\" needs the OncoKB annotation, which this server has no pinned version of"); + const covNames = (view.covariates || []).map(String); + { const e = overLimit("a predictive-biomarker view", PRED_MAX_COVARIATES, covNames.length); if (e) return refuse("predictive", ctx, e.replace("at most " + PRED_MAX_COVARIATES, "at most " + PRED_MAX_COVARIATES + " covariates")); } + for (const a of covNames) if (!/^[A-Za-z0-9_:/.-]{1,128}$/.test(a)) return refuse("predictive", ctx, "“" + a.slice(0, 40) + "” is not a clinical attribute's name"); + if (new Set(covNames).size !== covNames.length) return refuse("predictive", ctx, "a covariate is listed twice"); + const minMutated = view.minMutated == null ? 5 : Number(view.minMutated); + if (!(Number.isInteger(minMutated) && minMutated >= 1 && minMutated <= 1000)) return refuse("predictive", ctx, "minMutated is a whole number of patients, 1 to 1000"); + + // per-patient rows from each patient-level study; any other participant is refused by view coverage + const rows: PredRow[] = []; + const queries: NonNullable = []; + let notProfiled = 0, noCovariate = 0; + const panelGenes = new Map>>(); + for (const n of vNames) { + for (const st of cohorts[n].studies) { + const r = ctx.resolvers[st]; + if (!isPatientLevel(r) || !hasViewAggregates(r)) return refuse("predictive", ctx, st + " answers in aggregate only; a per-gene Cox model needs each patient's rows, which only a patient-level participant returns"); + const mark = r.queries.length; + const [got, panels] = await Promise.all([ + r.viewRows("predictive:rows", doc.cohorts[n], (c, s, d) => riskmodelRowsSql(c, s, d, { endpoint, columns, covariates: covNames, driverPin: measure === "driver" ? ctx.pin : null, anchor: exposure })), + panelGenes.has(st) ? Promise.resolve(null) : r.viewMeta("predictive:panels", (s, d) => riskmodelPanelGenesSql(s, d)), + ]); + for (const q of r.queries.slice(mark)) queries.push({ study: st, label: q.label, sql: q.sql, ...(q.ms != null ? { ms: q.ms } : {}) }); + if (panels) panelGenes.set(st, new Map(panels.map((x) => [String(x.panel), new Set(((x.genes as unknown as string[]) || []).map(String))] as const))); + for (const x of got) { + const pan = ((x.panels as unknown as string[]) || []).map(String); + if (!pan.length) { notProfiled++; continue; } + const cov = covNames.map((_, k) => String(x["c" + k] ?? "")); + if (cov.some((v) => !v)) { noCovariate++; continue; } + rows.push({ t: Number(x.m), ev: Number(x.ev) === 1 ? 1 : 0, exposed: Number(x.anchored) === 1, genes: new Set(((x.genes as unknown as string[]) || []).map(String)), + panels: pan, cov, stratum: (vNames.length > 1 ? n : "") + "|" + st, study: st }); + } + } + } + const exposedN = rows.filter((r) => r.exposed).length; + if (rows.length < 20) return refuse("predictive", ctx, "only " + rows.length + " patients have a " + endpointLabel + " time, a mutation panel and every covariate; the model needs at least 20"); + if (!exposedN || exposedN === rows.length) + return refuse("predictive", ctx, (exposedN ? "every" : "no") + " patient of " + plural(rows.length, "patient") + " with a survival time has a " + exposureLabel + " event, so there is no " + (exposedN ? "unexposed" : "exposed") + " group to compare"); + + // covariates coded as the cox view codes them + const covAxes: CoxCovariate[] = covNames.map((a) => ({ source: "clinical", attribute: a })); + const kinds: AxisKind[] = covNames.map((_, k) => rows.every((r) => Number.isFinite(Number(r.cov[k]))) ? "num" : "cat"); + const values = rows.map((r) => ({ values: r.cov.map((v, k) => kinds[k] === "num" ? Number(v) : v) })); + const covCols = buildCoxColumns(covAxes, kinds, values); + const xOf = (i: number) => covCols.map((c) => kinds[c.axisIndex] === "num" ? Number(values[i].values[c.axisIndex]) : (String(values[i].values[c.axisIndex]) === c.level ? 1 : 0)); + const stratified = new Set(rows.map((r) => r.stratum)).size > 1; + const covered = (r: PredRow, g: string) => r.panels.some((p) => p === "WES" || panelGenes.get(r.study)?.get(p)?.has(g)); + + const out: PredictiveGene[] = genes.map((gene) => { + const idx = rows.map((r, i) => covered(r, gene) ? i : -1).filter((i) => i >= 0); + const sub = idx.map((i) => ({ t: rows[i].t, ev: rows[i].ev, exposed: rows[i].exposed, mutated: rows[i].genes.has(gene), x: xOf(i), ...(stratified ? { stratum: rows[i].stratum } : {}) })); + const base = { gene, exposed: sub.filter((r) => r.exposed).length, unexposed: sub.filter((r) => !r.exposed).length, + mutatedExposed: sub.filter((r) => r.exposed && r.mutated).length, mutatedUnexposed: sub.filter((r) => !r.exposed && r.mutated).length, + events: sub.filter((r) => r.ev).length, inExposed: null, inUnexposed: null, interaction: null, q: null }; + if (!sub.length) return { ...base, skipped: "no patient's panel covers it" }; + if (base.mutatedExposed < minMutated || base.mutatedUnexposed < minMutated) + return { ...base, skipped: "mutated in " + base.mutatedExposed + " exposed and " + base.mutatedUnexposed + " unexposed patients; fitted only with at least " + minMutated + " in each" }; + if (base.mutatedExposed === base.exposed || base.mutatedUnexposed === base.unexposed) return { ...base, skipped: "every profiled patient of a group is mutated, so there is no wild-type to compare against" }; + try { + const f = fitPredictiveGene(sub); + if (![f.inExposed, f.inUnexposed, f.interaction].every((e) => Number.isFinite(e.hr) && Number.isFinite(e.ciHigh) && e.ciHigh < 1e6)) + return { ...base, skipped: "the fit did not converge (an estimate ran to infinity: the gene's patients may all have the same outcome)" }; + return { ...base, inExposed: f.inExposed, inUnexposed: f.inUnexposed, interaction: f.interaction }; + } catch (e) { return { ...base, skipped: "the fit failed: " + (e as Error).message }; } + }); + const fitted = out.filter((g) => g.interaction); + const qs = benjaminiHochberg(fitted.map((g) => g.interaction!.p)); + fitted.forEach((g, i) => { g.q = qs[i]; }); + const multi = ctx.vStudies.length > 1; + const best = [...fitted].sort((a, b) => a.interaction!.p - b.interaction!.p)[0]; + return { + kind: "predictive", cohorts: vNames, queries, + predictive: { exposure: exposureLabel, endpoint: endpointLabel, measure, n: rows.length, exposed: exposedN, events: rows.filter((r) => r.ev).length, + notProfiled, noCovariate, covariates: covCols.map((c) => c.label), minMutated, strata: new Set(rows.map((r) => r.stratum)).size, genes: out }, + participation: ctx.participationFor(ctx.vStudies, true), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "For each gene, a Cox model of " + endpointLabel + " (Efron ties) on the gene (" + (measure === "driver" ? "an OncoKB driver mutation" : "any mutation") + "), exposure to " + exposureLabel + + " (any such timeline event) and their product" + (covCols.length ? ", adjusted for " + covNames.join(", ") + " (numbers as they are, categories against their commonest level)" : "") + + (stratified ? ", the baseline hazard stratified by " + (vNames.length > 1 ? "cohort" + (multi ? " and study" : "") : "study") : "") + ". " + + "The gene's hazard ratio among the exposed and among the unexposed are the same model's two contrasts (the interaction coded both ways), so they share its estimates; the interaction HR is their ratio, and its p asks whether the gene predicts " + + "benefit rather than merely prognosis. q is Benjamini–Hochberg over the " + fitted.length + " genes fitted. " + + "Each gene is fitted over the patients whose panel covers it: a patient not profiled for a gene is left out of that gene's fit, never counted as wild-type. " + + "BIASES: exposure is defined from the whole record, after the clock's start — a patient must survive long enough to be treated, so the exposed group is credited with time before their treatment (immortal-time bias), " + + "and treatment was not randomised but chosen for patients by their condition (confounding by indication). Both distort the exposure's own effect; the interaction is less exposed to them but not immune, " + + "and an interaction found here is a hypothesis for a randomised or emulated-trial design, not evidence of benefit. " + + "Left out: " + notProfiled + " patients with no mutation panel on record" + (covNames.length ? ", " + noCovariate + " missing a covariate" : "") + ". " + + (best ? "Strongest interaction: " + best.gene + " (p " + best.interaction!.p.toPrecision(2) + ", q " + best.q!.toPrecision(2) + "). " : "No gene had enough mutated patients in both groups to fit. ") + + "Computed from per-patient rows inside each patient-level study.", + }; +} diff --git a/lib/views/riskmodel-validate.ts b/lib/views/riskmodel-validate.ts new file mode 100644 index 0000000..c9f897c --- /dev/null +++ b/lib/views/riskmodel-validate.ts @@ -0,0 +1,80 @@ +// External validation of a fitted risk model (RiskmodelView.validateOn): another cohort's patients +// scored with the ensemble's mean coefficients, cut at the training thresholds, and judged by +// Harrell's C-index, Kaplan–Meier curves by risk group, the log-rank test and each group's HR. +import type { RiskExternal } from "../types.ts"; +import { concordance, type CoxSubject } from "../coxnet.ts"; +import { kaplanMeier, logRankK, coxRegression, seededRandom } from "../stats.ts"; +import { keepRiskRows, riskFitSpec, riskViewRows, RiskRefusal, type RiskFit, type RiskFitArgs, type RiskGrouping, type RiskResolver } from "./riskmodel.ts"; + +/** bootstrap resamples for the C-index interval */ +export const RISK_EXTERNAL_BOOTSTRAP = 200; +const RISK_EXTERNAL_MIN = 30; + +/** + * Score the validation cohort's patients (`args` names its study and definition; the model's + * settings are the fit's own) and report on them. Each patient's score is the ensemble's mean + * linear predictor — every gene's coefficient averaged over all runs, zero where a run dropped it + * (its share of runs kept × its mean where kept) — the same average the fit's own `validate` half + * is scored with. A patient whose panels do not cover every gene the model weighs is left out and + * counted, never scored as wild-type for a gene they were not tested for. Throws RiskRefusal when + * too few patients remain. + */ +export async function riskExternal(r: RiskResolver, args: RiskFitArgs, cohort: string, fit: RiskFit, g: RiskGrouping, seed: number): Promise<{ external: RiskExternal; queries: RiskFit["queries"] }> { + const s = riskFitSpec(args.view, args.compile.oncokbPin), mark = r.queries.length; + const logged = () => r.queries.slice(mark).map((q) => ({ label: q.label, sql: q.sql, ...(q.ms != null ? { ms: q.ms } : {}) })); + const [rows, panelRows] = await riskViewRows(r, args); + const panelGenes = new Map(panelRows.map((x) => [String(x.panel), new Set(((x.genes as unknown as string[]) || []).map(String))] as const)); + const { kept, left } = keepRiskRows(rows, s); + const coef = new Map(fit.features.map((f) => [f.gene, f.selected * f.meanCoef] as const).filter(([, b]) => b !== 0)); + const model = [...coef.keys()]; + let notCovered = 0; + const scored = kept.filter((k) => { + const ok = k.panels.includes("WES") || model.every((gene) => k.panels.some((p) => panelGenes.get(p)?.has(gene))); + if (!ok) notCovered++; + return ok; + }); + if (scored.length < RISK_EXTERNAL_MIN) + throw new RiskRefusal("“" + cohort + "” has " + scored.length + " patients a validation can score (" + kept.length + " with a survival time, a panel" + + (s.strata ? " and a stratum" : "") + "; " + notCovered + " of them on panels missing a gene the model weighs); validation needs at least " + RISK_EXTERNAL_MIN, logged()); + const strata = [...new Set(scored.map((k) => k.stratum))].sort(); + const sIndex = new Map(strata.map((v, i) => [v, i] as const)); + const subj: CoxSubject[] = scored.map((k) => ({ time: k.t, event: k.ev, entry: k.entry, stratum: sIndex.get(k.stratum)! })); + const score = Float64Array.from(scored, (k) => k.genes.reduce((a, gene) => a + (coef.get(gene) ?? 0), 0)); + const all = Int32Array.from(scored, (_, i) => i); + const cIndex = concordance(subj, all, score); + const rand = seededRandom(seed), boots: number[] = []; + for (let b = 0; b < RISK_EXTERNAL_BOOTSTRAP; b++) { + const rowsB = Int32Array.from(all, () => Math.floor(rand() * all.length)); + const c = concordance(subj, rowsB, Float64Array.from(rowsB, (i) => score[i])); + if (Number.isFinite(c)) boots.push(c); + } + boots.sort((x, y) => x - y); + const q = (f: number) => boots.length ? boots[Math.min(boots.length - 1, Math.max(0, Math.round(f * (boots.length - 1))))] : NaN; + const cuts = g.groups.slice(1).map((x) => x.from!).filter((c) => c != null); + const groupOf = (v: number) => { let k = 0; while (k < cuts.length && v > cuts[k]) k++; return k; }; + const members: number[][] = g.groups.map(() => []); + const gOf = Int32Array.from(score, (v) => groupOf(v)); + gOf.forEach((k, i) => members[k].push(i)); + const rec = (i: number) => ({ t: subj[i].time, event: subj[i].event ? 1 : 0, ...(fit.anchored ? { entry: subj[i].entry } : {}) }); + let hrs: { hr: number; ciLow: number; ciHigh: number; p: number }[] | null = null; + const live = members.map((m, k) => k > 0 && m.length > 0); + if (members[0].length && live.some(Boolean)) { + try { + const cols = members.map((_, k) => k).filter((k) => live[k]); + const f = coxRegression(scored.map((_, i) => ({ t: subj[i].time, event: subj[i].event ? 1 : 0, x: cols.map((k) => gOf[i] === k ? 1 : 0), stratum: String(subj[i].stratum) }))); + hrs = members.map(() => null as unknown as { hr: number; ciLow: number; ciHigh: number; p: number }); + cols.forEach((k, j) => { hrs![k] = { hr: f.hr[j], ciLow: f.ciLow[j], ciHigh: f.ciHigh[j], p: f.p[j] }; }); + } catch { hrs = null; } + } + let logrank; + try { logrank = logRankK(strata.map((_, st) => ({ groups: members.map((m) => m.filter((i) => subj[i].stratum === st).map(rec)) }))); } catch { logrank = null; } + const external: RiskExternal = { + cohort, study: args.study, n: scored.length, events: scored.filter((k) => k.ev).length, cohortPatients: 0, rowsReturned: rows.length, + notProfiled: left.notProfiled, noStratum: left.noStratum, noAnchor: left.noAnchor, anchorAfterStart: left.anchorAfterStart, noFollowUp: left.noFollowUp, + notCovered, modelGenes: model.length, cIndex, cIndexLo: q(0.025), cIndexHi: q(0.975), bootstrap: RISK_EXTERNAL_BOOTSTRAP, cuts, + groups: g.groups.map((x, k) => ({ name: x.name, curve: kaplanMeier(members[k].map(rec)), + hr: hrs?.[k]?.hr ?? null, ciLow: hrs?.[k]?.ciLow ?? null, ciHigh: hrs?.[k]?.ciHigh ?? null, p: hrs?.[k]?.p ?? null })), + logrank, + }; + return { external, queries: logged() }; +} diff --git a/lib/views/riskmodel.ts b/lib/views/riskmodel.ts index feae8d9..544deaf 100644 --- a/lib/views/riskmodel.ts +++ b/lib/views/riskmodel.ts @@ -16,6 +16,7 @@ import type { ViewRun } from "./run.ts"; import { overLimit, refuse } from "./shared.ts"; import { RISK_LABEL_ABOVE, RISK_FIGURE_GENES, riskGroupNames } from "../figures/riskmodel.ts"; import { tableAttributesSql } from "../clinical-table.ts"; +import { riskExternal } from "./riskmodel-validate.ts"; import { buildTable1, table1DefaultCandidates, TABLE1_MAX_ATTRIBUTES, TABLE1_MAX_GENES, type Table1Patient } from "./riskmodel-table1.ts"; const RISK_MAX_RUNS = 100; @@ -43,13 +44,13 @@ export interface RiskFitSpec { } /** - * The view's settings that decide only how its groups are cut, drawn and described (Table 1), never the model: the one + * The view's settings that decide only how its groups are cut, drawn and described (Table 1, the external validation), never the model: the one * list a setting must be on to be left out of the fit's cache key. Everything else in the view — * including any setting added later — is in the key, so a setting nobody thought about costs at * worst a needless refit, never a fit served for a different model. A setting goes here only if * riskFitSpec does not read it (test/riskmodel-groups.test.ts checks each one). */ -export const RISK_PRESENTATION_KEYS = ["groups", "cuts", "labelAbove", "note", "cohorts", "describe"] as const; +export const RISK_PRESENTATION_KEYS = ["groups", "cuts", "labelAbove", "note", "cohorts", "describe", "validateOn"] as const; /** The view without its presentation settings, keys sorted: the part of the fit's key the view gives. */ export function riskModelView(view: RiskmodelView): RiskmodelView { @@ -149,7 +150,7 @@ export function keepRiskRows(rows: Record[], s: Pick { const s = riskFitSpec(args.view, args.compile.oncokbPin); @@ -214,7 +215,7 @@ export async function fitRiskmodel(r: RiskResolver, args: RiskFitArgs): Promise< * The fit's two queries — and Table 1's, which are the same with its attributes added — through the * patient-level resolver alone (test/boundary.test.ts sweeps their SQL). */ -function riskViewRows(r: RiskResolver, args: RiskFitArgs, table1?: { describe: string[] }) { +export function riskViewRows(r: RiskResolver, args: RiskFitArgs, table1?: { describe: string[] }) { const s = riskFitSpec(args.view, args.compile.oncokbPin); return Promise.all([ r.viewRows(table1 ? "riskmodel:table1" : "riskmodel:rows", args.def, (c, st, d) => riskmodelRowsSql(c, st, d, { endpoint: s.endpoint, columns: s.columns ?? undefined, strata: s.strata, @@ -449,6 +450,17 @@ export async function runRiskmodel(view: RiskmodelView, ctx: ViewRun): Promise TABLE1_MAX_ATTRIBUTES ? " attributes" : " genes"))); } + const validateOn = view.validateOn == null ? null : String(view.validateOn); + let vStudy: string | null = null; + if (validateOn != null) { + if (!Object.hasOwn(cohorts, validateOn)) return refuse("riskmodel", ctx, "validateOn names a cohort of this document; “" + validateOn.slice(0, 60) + "” is not one (it has " + Object.keys(cohorts).map((c) => "“" + c + "”").join(", ") + ")"); + if (validateOn === name) return refuse("riskmodel", ctx, "validateOn must name a different cohort from the one the model is fitted on"); + if (cohorts[validateOn].studies.length !== 1) return refuse("riskmodel", ctx, "a validation cohort is scored within one study; “" + validateOn + "” spans " + cohorts[validateOn].studies.length); + vStudy = cohorts[validateOn].studies[0]; + const rv = ctx.resolvers[vStudy]; + if (!rv || !isPatientLevel(rv) || !hasViewAggregates(rv)) return refuse("riskmodel", ctx, vStudy + " answers in aggregate only; scoring “" + validateOn + "” needs each patient's rows, which only a patient-level participant returns"); + if (covNames.length) return refuse("riskmodel", ctx, "validateOn scores the genes alone; a model with covariates cannot be carried to another cohort yet — drop the covariates or validateOn"); + } if (cohorts[name].count * runs > RISK_WORK_LIMIT) return refuse("riskmodel", ctx, "a risk model fits at most " + RISK_WORK_LIMIT.toLocaleString("en-US") + " patients × runs in one request; “" + name + "” has " + cohorts[name].count.toLocaleString("en-US") + " patients, so at most " + Math.max(1, Math.floor(RISK_WORK_LIMIT / Math.max(1, cohorts[name].count))) + " runs (or narrow the cohort)"); @@ -472,6 +484,18 @@ export async function runRiskmodel(view: RiskmodelView, ctx: ViewRun): Promise> | null = null; + if (validateOn != null && vStudy) { + const rv = ctx.resolvers[vStudy] as typeof r; + const vp = await ctx.studyProfiles(vStudy); + try { ext = await riskExternal(rv, { ...args, study: vStudy, def: doc.cohorts[validateOn], compile: { oncokbPin: ctx.pin, profiles: vp.profiles, gaProfiles: vp.gaProfiles } }, validateOn, fit, g, seed); } + catch (e) { + if (!(e instanceof RiskRefusal)) throw e; + return { ...refuse("riskmodel", ctx, e.message), queries: [...fit.queries.map((q) => ({ study, ...q })), ...e.queries.map((q) => ({ study: vStudy!, ...q }))] }; + } + ext.external.cohortPatients = cohorts[validateOn].count; + } + const cs = [...fit.cIndexRuns].filter((c) => Number.isFinite(c)).sort((x, y) => x - y); const pct = (f: number) => cs.length ? cs[Math.min(cs.length - 1, Math.round(f * (cs.length - 1)))] : NaN; const result: RiskmodelResult = { @@ -486,9 +510,10 @@ export async function runRiskmodel(view: RiskmodelView, ctx: ViewRun): Promise ({ study, ...q })), + kind: "riskmodel", cohorts: vNames, riskmodel: result, queries: [...[...fit.queries, ...(t1 ? t1.queries : [])].map((q) => ({ study, ...q })), ...(ext ? ext.queries.map((q) => ({ study: vStudy!, ...q })) : [])], method: riskmodelMethod({ study, cohort: name, cohortPatients: cohorts[name].count, rowsReturned: fit.rowsReturned, r: result, lambdas: RISK_LAMBDAS, folds: RISK_FOLDS, testShare: RISK_TEST_SHARE, table1Histograms: t1 ? t1.histograms : [] }), participation: ctx.participationFor([study], true), @@ -505,6 +530,11 @@ export async function runRiskmodel(view: RiskmodelView, ctx: ViewRun): Promise r.t))].sort((a, b) => a - b); + const n = atRiskAtTimes(recs, times); + for (let i = times.length - 1; i >= 0; i--) if (n[i] >= k) return times[i]; + return 0; +} + +/** the records still followed at `L` (alive and uncensored: t > L), on a clock restarted there */ +export function fromLandmark(recs: SurvRecord[], L: number): SurvRecord[] { + if (!(L > 0)) return recs; + return recs.filter((r) => r.t > L).map((r) => (r.entry != null && r.entry > L ? { t: r.t - L, event: r.event, entry: r.entry - L } : { t: r.t - L, event: r.event })); +} + +/** cohort `a` against `b`: the RMST difference and ratio (log scale), each with its Wald interval and p */ +export function rmstContrast(a: { rmst: number; se: number }, b: { rmst: number; se: number }): + Omit { + const diff = a.rmst - b.rmst, seD = Math.sqrt(a.se * a.se + b.se * b.se); + const lr = Math.log(a.rmst / b.rmst), seR = Math.sqrt((a.se / a.rmst) ** 2 + (b.se / b.rmst) ** 2); + return { diff, diffLo: diff - Z_975 * seD, diffHi: diff + Z_975 * seD, diffP: seD > 0 ? normalP(diff / seD) : 1, + ratio: Math.exp(lr), ratioLo: Math.exp(lr - Z_975 * seR), ratioHi: Math.exp(lr + Z_975 * seR), ratioP: seR > 0 ? normalP(lr / seR) : 1 }; +} + +export async function runRmst(view: RmstView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + { const e = overLimit("an RMST view", RMST_COHORT_LIMIT, vNames.length); if (e) return refuse("rmst", ctx, e.replace("at most " + RMST_COHORT_LIMIT, "at most " + RMST_COHORT_LIMIT + " cohorts")); } + const endpoint = String(view.endpoint || "OS").toUpperCase().trim(); + if (!/^[A-Z][A-Z0-9_]{0,39}$/.test(endpoint)) return refuse("rmst", ctx, "bad endpoint '" + endpoint.slice(0, 40) + "'"); + if (!!view.monthsAttribute !== !!view.statusAttribute) return refuse("rmst", ctx, "monthsAttribute and statusAttribute go together"); + for (const a of [view.monthsAttribute, view.statusAttribute]) if (a != null && !ATTR_NAME.test(String(a))) return refuse("rmst", ctx, "“" + String(a).slice(0, 40) + "” is not an attribute name"); + const columns: EndpointColumns | undefined = view.monthsAttribute ? { months: String(view.monthsAttribute), status: String(view.statusAttribute) } : undefined; + let anchor: TreatmentAnchor | null = null, anchoredTo: string | undefined; + if (view.anchor != null) { + const an = treatmentAnchor(view.anchor, "an RMST view's anchor"); + if ("error" in an) return refuse("rmst", ctx, an.error); + if (endpoint !== "OS" || columns) return refuse("rmst", ctx, "an anchor measures overall survival from the treatment on the timeline's clock (OS_MONTHS / OS_STATUS); drop endpoint / monthsAttribute to use it"); + ({ anchor, anchoredTo } = an); + } + const landmarks = (view.landmarkMonths == null ? [0] : view.landmarkMonths).map(Number); + if (!Array.isArray(view.landmarkMonths ?? []) || !landmarks.length || landmarks.length > RMST_LANDMARK_LIMIT || landmarks.some((l) => !(Number.isFinite(l) && l >= 0 && l <= 120)) || + landmarks.some((l, i) => i > 0 && !(l > landmarks[i - 1]))) + return refuse("rmst", ctx, "landmarkMonths is 1 to " + RMST_LANDMARK_LIMIT + " increasing month values from 0 to 120, e.g. [0, 6, 12]"); + let tauSet: number | null = null; + if (view.tau != null) { + const t = Number(view.tau); + if (!(Number.isFinite(t) && t > 0 && t <= 600)) return refuse("rmst", ctx, "tau is a positive number of months (at most 600)"); + tauSet = t; + } + const endpointLabel = anchor ? "OS from " + anchoredTo : columns ? columns.months + "/" + columns.status : endpoint; + + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return refuse("rmst", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + const recsBy = new Map(); + const tally: RmstResultCohort[] = []; + const lateBy = new Map(); + const opts: TxEndpointOpts | null = anchor ? { endpoint: "os", anchor, lines: { gap: DEFAULT_LINE_GAP, eventType: TREATMENT_EVENT } } : null; + for (const n of vNames) { + const recs: SurvRecord[] = []; + let cohortN = 0, noFollowup = 0, anchored = 0; + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + cohortN += cohorts[n].perStudy[st]?.count || 0; + if (!opts) { + const counts = await ok[st].survivalCounts(doc.cohorts[n], endpoint, null, columns); + recs.push(...expandSurvivalCounts(counts)); + return; + } + const rows = await ok[st].viewAggregate("rmst:anchor:" + n, doc.cohorts[n], (c, s, d) => txEndpointCountsSql(c, s, d, opts)); + let siteLate = 0, siteAnch = 0; + for (const r of rows) { + const cnt = Number(r.n); + anchored += cnt; siteAnch += cnt; + if (Number(r.late)) siteLate += cnt; + if (String(r.kind) !== "record") { noFollowup += cnt; continue; } + const entry = Number(r.entry), t = Number(r.t), event = Number(r.ev); + for (let i = 0; i < cnt; i++) recs.push(entry > 0 ? { t, event, entry } : { t, event }); + } + const L = lateBy.get(st) || { late: 0, anchored: 0 }; + lateBy.set(st, { late: L.late + siteLate, anchored: L.anchored + siteAnch }); + })); + recs.sort((x, y) => x.t - y.t || x.event - y.event || (x.entry ?? 0) - (y.entry ?? 0)); + recsBy.set(n, recs); + tally.push({ cohort: n, cohortPatients: cohortN, n: recs.length, noFollowup: opts ? noFollowup : cohortN - recs.length, noAnchor: opts ? Math.max(0, cohortN - anchored) : 0 }); + } + const badClock = clockFailures(lateBy); + if (badClock.length) + return refuse("rmst", ctx, "the timeline and OS_MONTHS do not share a clock in " + badClock.map(([st, x]) => st + " (" + plural(x.late, "anchored patient") + " of " + + x.anchored.toLocaleString("en-US") + " have timeline events more than " + CLOCK_SLACK_DAYS + " days after OS_MONTHS)").join(", ") + ", so survival cannot be measured from the treatment"); + if (anchor && tally.every((c) => c.noAnchor === c.cohortPatients)) { + const first = Object.keys(ok)[0]; + const vals = anchor.key ? await ok[first].viewAggregate("rmst:anchor-values", doc.cohorts[vNames[0]], (c, s, d) => labTestsSql(c, s, d, anchor.eventType, anchor.key!)).catch(() => []) : []; + return refuse("rmst", ctx, noAnchorMessage(anchor, vNames, vals)); + } + if (tally.every((c) => !c.n)) { + if (tally.every((c) => !c.cohortPatients)) return refuse("rmst", ctx, "nothing to estimate: the cohorts are empty"); + const eps = await Promise.all(Object.entries(ok).map(async ([st, r]) => st + ": " + ((await r.survivalEndpoints().catch(() => [] as string[])).join(", ") || "none declared"))); + return refuse("rmst", ctx, "no patient of these cohorts has " + (columns ? columns.months + "/" + columns.status : endpoint + "_MONTHS/" + endpoint + "_STATUS") + + (anchor ? " follow-up after the anchor" : "") + ". Endpoints declared: " + eps.join("; ")); + } + + const panels: RmstPanel[] = []; + for (const L of landmarks) { + const at = new Map(vNames.map((n) => [n, fromLandmark(recsBy.get(n)!, L)])); + const lastT = vNames.map((n) => Math.max(0, ...at.get(n)!.map((r) => r.t))); + let tau: number; + if (tauSet != null) { + const short = vNames.filter((_, i) => lastT[i] < tauSet!); + if (short.length) return refuse("rmst", ctx, "tau = " + tauSet + " months is past the last follow-up" + (L > 0 ? " from the " + L + "-month landmark" : "") + " of " + + short.map((n) => n + " (" + (Math.round(lastT[vNames.indexOf(n)] * 10) / 10) + " months)").join(", ") + ": the curve is unknown there, so its area is too — choose a smaller tau"); + tau = tauSet; + } else { + const each = vNames.map((n) => lastTimeWithAtRisk(at.get(n)!, RMST_MIN_AT_RISK)); + tau = Math.floor(Math.min(...each) * 10) / 10; + if (!(tau > 0)) return refuse("rmst", ctx, "every cohort needs at least " + RMST_MIN_AT_RISK + " patients at risk" + (L > 0 ? " after the " + L + "-month landmark" : "") + + " to set tau; " + vNames.filter((_, i) => !(each[i] > 0)).join(", ") + " " + (vNames.filter((_, i) => !(each[i] > 0)).length === 1 ? "has" : "have") + " fewer — set tau, or drop the landmark"); + } + const curves: RmstCurve[] = vNames.map((n) => { + const recs = at.get(n)!, r = rmst(recs, tau); + return { cohort: n, ...kaplanMeier(recs), rmst: r.rmst, se: r.se, lo: r.lo, hi: r.hi, atRiskTau: atRiskAtTimes(recs, [tau])[0], beforeLandmark: recsBy.get(n)!.length - recs.length }; + }); + const ref = curves[0]; + const comparisons: RmstComparison[] = curves.slice(1).filter((c) => c.n && ref.n).map((c) => ({ cohort: c.cohort, vs: ref.cohort, ...rmstContrast(c, ref) })); + panels.push({ landmark: L, tau, tauSet: tauSet != null, curves, comparisons }); + } + + const multi = ctx.vStudies.length > 1; + const lateTotal = [...lateBy.values()].reduce((a, x) => a + x.late, 0), anchTotal = [...lateBy.values()].reduce((a, x) => a + x.anchored, 0); + const tauWords = tauSet != null ? "τ = " + tauSet + " months, as set" + : "τ by default the largest time at which every cohort still has " + RMST_MIN_AT_RISK + " patients at risk (" + panels.map((p) => (p.landmark ? p.landmark + "-month landmark: " : "") + p.tau + " months").join("; ") + ")"; + return { + kind: "rmst", cohorts: vNames, + rmst: { endpointLabel, ...(anchoredTo ? { anchoredTo } : {}), panels, cohorts: tally, ...(excluded.length ? { excludedStudies: excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: "Restricted mean survival time: the area under each cohort's Kaplan–Meier curve from 0 to τ — the mean months " + (endpoint === "OS" ? "lived" : "free of the event") + + " within the first τ — with " + tauWords + ". Its standard error is the Greenwood-type variance of that area (Klein & Moeschberger; R survRM2); differences and ratios against " + vNames[0] + + " use Wald intervals (the ratio on the log scale) and two-sided normal p. Unlike a hazard ratio, RMST needs no proportional hazards, so it stays interpretable when curves cross or separate late, as on immunotherapy. " + + (anchor ? "The clock starts at each patient's " + anchoredTo + " record; death is OS_STATUS 1:DECEASED, censoring at the last follow-up (OS_MONTHS × " + DAYS_PER_MONTH + + " days on the timeline's clock; checked: " + lateTotal.toLocaleString("en-US") + " of " + anchTotal.toLocaleString("en-US") + " anchored patients have a timeline event more than " + CLOCK_SLACK_DAYS + + " days past it, a study above " + Math.round(CLOCK_TOLERANCE * 100) + "% is refused); an anchor before day 0 enters the risk set at day 0 (left truncation). " + + "Left out: " + tally.reduce((a, c) => a + c.noAnchor, 0).toLocaleString("en-US") + " without the anchor, " : "Endpoint " + endpointLabel + " from the study's day 0. Left out: ") + + tally.reduce((a, c) => a + c.noFollowup, 0).toLocaleString("en-US") + " without follow-up. " + + (landmarks.some((l) => l > 0) ? "A landmark keeps only the patients alive and still followed at it and restarts their clock there, so each later panel answers “for those who got this far” and is free of the guarantee-time bias of grouping by what happens after the start; its patients are fewer, and its τ is counted from the landmark. " : "") + + "Records are pooled across a cohort's studies without stratification" + (multi ? " (" + ctx.vStudies.length + " studies here, whose day 0 need not match)" : "") + ". " + + "The cohorts are not randomised, so a difference is an association. " + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + ". " : "") + + "Each site sends only (entry, time, event) counts.", + }; +} + +type RmstResultCohort = { cohort: string; cohortPatients: number; n: number; noFollowup: number; noAnchor: number }; diff --git a/lib/views/shared.ts b/lib/views/shared.ts index 715b4d4..a0f7472 100644 --- a/lib/views/shared.ts +++ b/lib/views/shared.ts @@ -5,6 +5,7 @@ import { hasViewAggregates, type Resolver, type ViewAggregateCapable } from "../ import { geneCytobandsSql } from "../federation-views.ts"; import type { ViewRun } from "./run.ts"; import type { StudyCatalog } from "../plot-profile.ts"; +import { VIEW_ATTR_RE } from "../custom-data-names.ts"; /** 12 genes = 66 pairs; beyond this the multiplicity correction eats the signal */ export const COOCCUR_GENE_LIMIT = 12; @@ -18,8 +19,8 @@ export function overLimit(what: string, max: number, given: number): string | nu " rather than have the rest silently ignored" : null; } -/** a clinical attribute name as a view may name it */ -export const ATTR_NAME = /^[A-Za-z0-9_.-]{1,64}$/; +/** a clinical attribute name as a view may name it: a built-in one, or an uploaded column (`custom:/`, lib/custom-data-names.ts) */ +export const ATTR_NAME = VIEW_ATTR_RE; /** a molecular or generic-assay profile type, as a view names it */ export const PROFILE_NAME = /^[A-Za-z0-9_.-]{1,96}$/; diff --git a/lib/views/survival.ts b/lib/views/survival.ts index 73e9229..6b27081 100644 --- a/lib/views/survival.ts +++ b/lib/views/survival.ts @@ -5,7 +5,7 @@ import type { ViewResult, LogRank, LogRankK, SurvivalView } from "../types.ts"; import { expandSurvivalCounts, type SurvivalCount, type EndpointColumns } from "../federation.ts"; import { survivalMethod } from "../method.ts"; import { untimed, type Untimed, type ViewRun, type DocView } from "./run.ts"; -import { askEachSite } from "./shared.ts"; +import { ATTR_NAME, askEachSite } from "./shared.ts"; export async function runSurvival(view: DocView, c: ViewRun): Promise { const { doc, cohorts, resolvers, vNames, vStudies, participationFor } = c; @@ -26,9 +26,9 @@ export async function runSurvival(view: DocView, c: ViewRun): Promise/): got '" + monthsAttr.slice(0, 40) + "'/'" + statusAttr.slice(0, 40) + "'" }); } columns = { months: monthsAttr, status: statusAttr }; diff --git a/lib/views/txendpoints.ts b/lib/views/txendpoints.ts new file mode 100644 index 0000000..837ddda --- /dev/null +++ b/lib/views/txendpoints.ts @@ -0,0 +1,138 @@ +// Real-world treatment endpoints view: time to next treatment, time on treatment or real-world +// progression-free survival from a treatment anchor, as Kaplan–Meier curves per cohort with a +// log-rank test. Each participant returns id-free (entry, t, event, n) counts. +import type { ViewResult, TxendpointsView, TxendpointsCurve, LogRankK } from "../types.ts"; +import { txEndpointCountsSql, labTestsSql, CLOCK_SLACK_DAYS, DAYS_PER_MONTH, type TxEndpointOpts } from "../federation-views.ts"; +import { kaplanMeier, kmMedianCi, logRankK, applyCutoff, type SurvRecord } from "../stats.ts"; +import { DEFAULT_LINE_GAP, TREATMENT_EVENT } from "../treatment-lines.ts"; +import type { ViewRun } from "./run.ts"; +import { overLimit, answering, refuse, plural } from "./shared.ts"; +import { progressionEvent, progressionError, progressionCondition, clockFailures, treatmentAnchor, noAnchorMessage, CLOCK_TOLERANCE } from "../timeline-endpoints.ts"; + +const TX_COHORT_LIMIT = 6; +/** below this many patients in a cohort the log-rank test is refused (the survival view's floor) */ +const TX_MIN_ARM_N = 10; + +const TX_ENDPOINT_LABELS = { + ttnt: "time to next treatment", tot: "time on treatment", rwpfs: "real-world progression-free survival", +} as const; + +export async function runTxendpoints(view: TxendpointsView, ctx: ViewRun): Promise { + const { doc, cohorts, vNames } = ctx; + { const e = overLimit("a treatment-endpoints view", TX_COHORT_LIMIT, vNames.length); if (e) return refuse("txendpoints", ctx, e.replace("at most " + TX_COHORT_LIMIT, "at most " + TX_COHORT_LIMIT + " cohorts")); } + const endpoint = view.endpoint ?? "ttnt"; + if (!(endpoint in TX_ENDPOINT_LABELS)) return refuse("txendpoints", ctx, "endpoint is ttnt (time to next treatment), tot (time on treatment) or rwpfs (real-world PFS); got '" + String(endpoint).slice(0, 20) + "'"); + const an = treatmentAnchor(view.anchor, "a treatment-endpoints view"); + if ("error" in an) return refuse("txendpoints", ctx, an.error); + const { anchor, anchoredTo } = an; + const lineGap = view.lineGap == null ? DEFAULT_LINE_GAP : Number(view.lineGap); + if (!(Number.isInteger(lineGap) && lineGap >= 1 && lineGap <= 3650)) return refuse("txendpoints", ctx, "lineGap is a whole number of days, 1 to 3650"); + const lc = view.lineClasses; + if (lc != null && lc !== "systemic" && lc !== "all" && !(Array.isArray(lc) && lc.length && lc.length <= 40 && lc.every((c) => typeof c === "string" && c.trim()))) + return refuse("txendpoints", ctx, "lineClasses is \"systemic\" (default), \"all\", or a list of up to 40 treatment classes"); + const lineClasses = lc ?? "systemic"; + const progression = progressionEvent(view.progression); + if (endpoint === "rwpfs") { const e = progressionError(progression); if (e) return refuse("txendpoints", ctx, e); } + const ongoingDays = view.ongoingDays == null ? 30 : Number(view.ongoingDays); + if (!(Number.isInteger(ongoingDays) && ongoingDays >= 0 && ongoingDays <= 365)) return refuse("txendpoints", ctx, "ongoingDays is a whole number of days, 0 to 365"); + let cutoffMonths: number | undefined; + if (view.cutoffMonths != null) { + const c = Number(view.cutoffMonths); + if (!(Number.isFinite(c) && c > 0 && c <= 1200)) return refuse("txendpoints", ctx, "cutoffMonths is a positive number of months (at most 1200)"); + cutoffMonths = c; + } + + const { ok, excluded } = answering(ctx); + if (!Object.keys(ok).length) return refuse("txendpoints", ctx, "no participant can answer: " + excluded.map((e) => e.study + " is " + e.reason).join("; ")); + const opts: TxEndpointOpts = { endpoint, anchor, lines: { gap: lineGap, eventType: TREATMENT_EVENT, classes: lineClasses }, + ...(endpoint === "rwpfs" ? { progression } : {}), ...(endpoint === "tot" ? { ongoingDays } : {}) }; + const perSite = new Map>(); + const curves: TxendpointsCurve[] = []; + const lateBy = new Map(); + for (const n of vNames) { + const sites = new Map(); + perSite.set(n, sites); + const ex = { noAnchor: 0, noFollowup: 0, noStop: 0 }; + let late = 0, anchored = 0, cohortN = 0; + await Promise.all(cohorts[n].studies.filter((st) => ok[st]).map(async (st) => { + cohortN += cohorts[n].perStudy[st]?.count || 0; + const rows = await ok[st].viewAggregate("txendpoints:" + endpoint + ":" + n, doc.cohorts[n], (c, s, d) => txEndpointCountsSql(c, s, d, opts)); + const recs: SurvRecord[] = []; + let siteLate = 0, siteAnch = 0; + for (const r of rows) { + const k = String(r.kind), cnt = Number(r.n); + anchored += cnt; siteAnch += cnt; + if (Number(r.late)) { late += cnt; siteLate += cnt; } + if (k === "nofollowup") { ex.noFollowup += cnt; continue; } + if (k === "nostop") { ex.noStop += cnt; continue; } + const entry = Number(r.entry), t = Number(r.t), event = Number(r.ev); + for (let i = 0; i < cnt; i++) recs.push(entry > 0 ? { t, event, entry } : { t, event }); + } + const L = lateBy.get(st) || { late: 0, anchored: 0 }; + lateBy.set(st, { late: L.late + siteLate, anchored: L.anchored + siteAnch }); + recs.sort((x, y) => x.t - y.t || x.event - y.event || (x.entry ?? 0) - (y.entry ?? 0)); + sites.set(st, cutoffMonths != null ? applyCutoff(recs, cutoffMonths) : recs); + })); + ex.noAnchor = Math.max(0, cohortN - anchored); + const all = [...sites.values()].flat(); + const km = kaplanMeier(all), ci = kmMedianCi(all); + curves.push({ cohort: n, ...km, medianLo: ci.lo, medianHi: ci.hi, cohortPatients: cohortN, anchored, excluded: ex, late }); + } + // the clock check: OS_MONTHS must be measured from the timeline's day 0, so that no event is recorded after the last follow-up + const badClock = clockFailures(lateBy); + if (badClock.length) + return refuse("txendpoints", ctx, "the timeline and OS_MONTHS do not share a clock in " + badClock.map(([st, x]) => st + " (" + plural(x.late, "anchored patient") + " of " + + x.anchored.toLocaleString("en-US") + " have timeline events more than " + CLOCK_SLACK_DAYS + " days after OS_MONTHS)").join(", ") + + ", so follow-up and death cannot be placed on the treatment's clock"); + if (!curves.some((c) => c.anchored)) { + const first = Object.keys(ok)[0]; + const vals = anchor.key ? await ok[first].viewAggregate("txendpoints:anchor-values", doc.cohorts[vNames[0]], (c, s, d) => labTestsSql(c, s, d, anchor.eventType, anchor.key!)).catch(() => []) : []; + return refuse("txendpoints", ctx, noAnchorMessage(anchor, vNames, vals)); + } + if (!curves.some((c) => c.n)) return refuse("txendpoints", ctx, "no anchored patient could enter the risk set: " + curves.map((c) => + c.cohort + " — " + c.excluded.noFollowup + " without follow-up after the anchor" + (endpoint === "tot" ? ", " + c.excluded.noStop + " without a stop date" : "")).join("; ")); + + const thin = curves.filter((c) => c.n < TX_MIN_ARM_N).map((c) => c.cohort + " (n=" + c.n + ")"); + let logrank: LogRankK | null = null, noTest: string | undefined; + if (vNames.length < 2) noTest = "a log-rank test needs at least two cohorts"; + else if (thin.length) noTest = "log-rank refused: " + thin.join(", ") + (thin.length === 1 ? " has" : " have") + " fewer than " + TX_MIN_ARM_N + " patients"; + else { + const studies = [...new Set(vNames.flatMap((n) => [...perSite.get(n)!.keys()]))].sort(); + logrank = logRankK(studies.map((st) => ({ groups: vNames.map((n) => perSite.get(n)!.get(st) || []) }))); + } + const tot = (k: keyof TxendpointsCurve["excluded"]) => curves.reduce((a, c) => a + c.excluded[k], 0); + const unobserved = curves.reduce((a, c) => a + (c.unobserved || 0), 0); + const lateTotal = curves.reduce((a, c) => a + c.late, 0), anchTotal = curves.reduce((a, c) => a + c.anchored, 0); + const multi = ctx.vStudies.length > 1; + const linesWord = "treatment lines as lib/treatment-lines.ts builds them (" + (lineClasses === "systemic" ? "systemic-therapy records" : lineClasses === "all" ? "every treatment record" : "records of class " + (lineClasses as string[]).join(", ")) + + "; a record more than " + lineGap + " days after every earlier record's stop starts a new line)"; + const endpointWords = endpoint === "ttnt" + ? "Time to next treatment: from the start of the " + anchoredTo + " record to the start of the next line after the anchor's own, " + linesWord + ", or death, whichever comes first. " + : endpoint === "tot" + ? "Time on treatment: from the start of the " + anchoredTo + " record to the latest stop date of the anchored records in the same line (" + linesWord + "), or death. A living patient whose stop is within " + ongoingDays + + " days of their last follow-up is censored there as still on treatment. " + tot("noStop") + " anchored patients have an anchored record without a stop date and are left out: a missing stop cannot be told from an ongoing treatment. " + : "Real-world progression-free survival: from the start of the " + anchoredTo + " record to the first " + progression.eventType + " event after it with " + progressionCondition(progression) + ", or death. Progression read from the record (in MSK-CHORD, from notes by NLP) is only as complete as that recording; events on the anchor's own day are not counted. "; + return { + kind: "txendpoints", cohorts: vNames, + txendpoints: { endpoint, endpointLabel: TX_ENDPOINT_LABELS[endpoint], anchoredTo, lineGap, lineClasses: Array.isArray(lineClasses) ? lineClasses.join(", ") : lineClasses, + ...(endpoint === "rwpfs" ? { progression } : {}), ...(endpoint === "tot" ? { ongoingDays } : {}), ...(cutoffMonths != null ? { cutoffMonths } : {}), + curves, logrank, ...(noTest ? { noTest } : {}), ...(excluded.length ? { excludedStudies: excluded } : {}) }, + participation: ctx.participationFor(ctx.vStudies, false), + ...(multi ? { studies: ctx.vStudies } : {}), + caveat: endpointWords + + "Censored at the last follow-up, which is OS_MONTHS × " + DAYS_PER_MONTH + " days on the timeline's clock; death is OS_STATUS 1:DECEASED. That OS_MONTHS counts from the timeline's day 0 was checked on these patients: " + + lateTotal.toLocaleString("en-US") + " of " + anchTotal.toLocaleString("en-US") + " anchored patients " + (lateTotal === 1 ? "has" : "have") + " a timeline event more than " + CLOCK_SLACK_DAYS + + " days past it (a study above " + Math.round(CLOCK_TOLERANCE * 100) + "% is refused). " + + "Left truncation as in the survival view: a patient whose anchor falls before day 0 enters the risk set at day 0. Left out: " + tot("noAnchor") + " cohort patients with no " + anchoredTo.replace(/^(first|last) /, "") + " record, " + + tot("noFollowup") + " with no follow-up after it" + + (unobserved ? ", and " + unobserved.toLocaleString("en-US") + " anchored before day 0 whose endpoint also came before it, so were never at risk" : "") + ". " + + (cutoffMonths != null ? "Administratively censored at " + cutoffMonths + " months. " : "") + + "Medians with 95% intervals from the Greenwood band on the log scale. " + + (logrank ? logrank.groups + "-group log-rank (" + logrank.df + " df)" + (logrank.strata > 1 ? ", stratified by study (" + logrank.strata + " strata)" : "") + ". " : noTest ? noTest.charAt(0).toUpperCase() + noTest.slice(1) + ". " : "") + + "Treated patients are not randomised: the cohorts can differ in who was offered the treatment, when, and how sick they were, so a difference here is an association. " + + (multi ? "Records are pooled across " + ctx.vStudies.length + " studies, whose day 0 and recording need not match. " : "") + + (excluded.length ? "Left out: " + excluded.map((e) => e.study + " (" + e.reason + ")").join(", ") + ". " : "") + + "Each site sends only (entry, time, event) counts.", + }; +} + diff --git a/mcp/tools.ts b/mcp/tools.ts index 0eae292..6b76d98 100644 --- a/mcp/tools.ts +++ b/mcp/tools.ts @@ -428,7 +428,7 @@ export function registerTools(server: any, engine: ToolEngine = DIRECT): void { server.tool( "run_analysis", - "Execute a full Analysis document — the v2 DSL: named cohorts built from OQL genomic status, clinical attributes, timeline events, case lists, and continuous genomic data (optionally federated across studies), plus views: compare (alteration frequencies with Fisher p), clinical (attribute distributions per cohort), cooccurrence (pairwise 2x2 tests), hotspots (mutation lollipop), coexpression (gene-pair correlation), survival (Kaplan-Meier), cox (multivariate hazard ratios, forest plot), logistic (multivariate logistic regression on a binary outcome, odds ratios), course (event timelines), sequence (treatment-line flows), plot (scatter/box/table of any two axes), oncoprint, attrition (CONSORT-style counts after each cohort clause), genome (genome-wide copy-number gain/loss frequency from segments), pathways (the ten Sanchez-Vega oncogenic pathways, % altered per cohort), diffexpr (two-cohort differential expression volcano plus preranked GSEA), paired (primary-vs-metastasis or before-vs-after-treatment concordance within patients; omit genes to scan every gene, pairBy.studies to pool studies that label samples differently), clonality (FACETS clonal/subclonal calls, LOH, genome doubling, purity, ploidy), incidence (cumulative incidence of a timeline event with death as a competing risk, Gray's test), labs (a lab value's course and response from a treatment start, e.g. PSA50, with a waterfall), cancertypes (a gene list's alteration frequency per cancer type, the portal's Cancer Types Summary), overlap (how cohorts' patients intersect, as an UpSet plot), enrichment (every gene's alteration frequency compared between cohorts, the portal's Genomic Alterations tab), assays (every entity of a generic-assay profile — mutational signatures, arm-level copy number, treatment response — compared between cohorts), composition (what each case is made of — cell types, cell states, signatures — as stacked bars per case and each cohort's mean, every part of each cohort set against the others), mutations (one gene's mutation table: every call with sample, protein change, OncoKB annotation, allele frequency; patient-level studies only), table (the clinical data table: each sample or patient with every clinical attribute, patient-level studies only), timeline (one patient's clinical timeline as on cBioPortal's patient view — treatments, diagnoses, samples, lab values from diagnosis — with the cohorts' patients listed to pick from; patient-level studies only), alterations (one patient's Mutations, Copy Number Alterations and Structural Variants tables as on cBioPortal's patient view, each searchable, filterable and sortable, with the cohort's patients listed to pick from; patient-level studies only), correlates (every gene's expression correlated with one gene's, the portal's Co-expression tab), riskmodel (an OncoCast-style elastic-net Cox ensemble predicting survival from mutated genes, held-out C-index and Kaplan-Meier risk groups; patient-level studies only). Every view's figure is returned as an image. Call describe_dsl first for the grammar and runnable examples. Returns per-cohort counts with plain-language readbacks, view results, caveats you MUST surface to the user, and a link that opens this exact document in the interactive editor.", + "Execute a full Analysis document — the v2 DSL: named cohorts built from OQL genomic status, clinical attributes, timeline events, case lists, and continuous genomic data (optionally federated across studies), plus views: compare (alteration frequencies with Fisher p), clinical (attribute distributions per cohort), cooccurrence (pairwise 2x2 tests), hotspots (mutation lollipop), coexpression (gene-pair correlation), survival (Kaplan-Meier), cox (multivariate hazard ratios, forest plot), logistic (multivariate logistic regression on a binary outcome, odds ratios), course (event timelines), sequence (treatment-line flows), plot (scatter/box/table of any two axes), oncoprint, attrition (CONSORT-style counts after each cohort clause), genome (genome-wide copy-number gain/loss frequency from segments), pathways (the ten Sanchez-Vega oncogenic pathways, % altered per cohort), diffexpr (two-cohort differential expression volcano plus preranked GSEA), paired (primary-vs-metastasis or before-vs-after-treatment concordance within patients; omit genes to scan every gene, pairBy.studies to pool studies that label samples differently), clonality (FACETS clonal/subclonal calls, LOH, genome doubling, purity, ploidy), incidence (cumulative incidence of a timeline event with death as a competing risk, Gray's test), labs (a lab value's course and response from a treatment start, e.g. PSA50, with a waterfall), acquired (alterations acquired under therapy: each patient's sample before a treatment start against their sample after it, per gene acquired vs lost with McNemar q; omit genes to scan), ctdna (plasma cfDNA against tissue within patients: per gene both / tissue-only / plasma-only with positive percent agreement, the share with ctDNA detected, and overall survival by ctDNA detected), exclusivity (mutual exclusivity and co-occurrence of up to 20 genes against each tumour's alteration burden, DISCOVER, beside the plain Fisher test), hla (HLA class I genotype: the share homozygous at 0/1/2+ of HLA-A, -B, -C and with HLA LOH, and overall survival by each with log-rank and Cox HRs, optionally adjusted; patient-level studies only), cutpoint (the cut of a numeric marker such as TMB that best splits survival: scan between the 10th and 90th percentiles, permutation-corrected p beside the naive one, KM at the cut, optional per-stratum cuts; patient-level studies only), predictive (gene x treatment interaction per gene: the gene's survival HR among the treated and the untreated and their interaction p, BH q; patient-level studies only), txendpoints (real-world treatment endpoints from a treatment start — time to next treatment, time on treatment or real-world PFS — as Kaplan-Meier curves with log-rank), rmst (restricted mean survival time to tau per cohort with differences and ratios, for non-proportional hazards; landmark analysis from given months), multistate (an illness-death model — start, progression, death — from day 0: state occupation over time, cumulative hazards, transition rates and a hazard ratio per transition), instability (genomic instability from copy-number segments: fraction of genome altered, arm-level gains and losses and the aneuploidy score per cohort, with a rank test), cancertypes (a gene list's alteration frequency per cancer type, the portal's Cancer Types Summary), overlap (how cohorts' patients intersect, as an UpSet plot), enrichment (every gene's alteration frequency compared between cohorts, the portal's Genomic Alterations tab), assays (every entity of a generic-assay profile — mutational signatures, arm-level copy number, treatment response — compared between cohorts), composition (what each case is made of — cell types, cell states, signatures — as stacked bars per case and each cohort's mean, every part of each cohort set against the others), mutations (one gene's mutation table: every call with sample, protein change, OncoKB annotation, allele frequency; patient-level studies only), table (the clinical data table: each sample or patient with every clinical attribute, patient-level studies only), timeline (one patient's clinical timeline as on cBioPortal's patient view — treatments, diagnoses, samples, lab values from diagnosis — with the cohorts' patients listed to pick from; patient-level studies only), alterations (one patient's Mutations, Copy Number Alterations and Structural Variants tables as on cBioPortal's patient view, each searchable, filterable and sortable, with the cohort's patients listed to pick from; patient-level studies only), correlates (every gene's expression correlated with one gene's, the portal's Co-expression tab), riskmodel (an OncoCast-style elastic-net Cox ensemble predicting survival from mutated genes, held-out C-index and Kaplan-Meier risk groups; patient-level studies only), forest (one exposure's hazard ratio per cancer type, study or cohort, pooled by a DerSimonian–Laird random-effects meta-analysis with I² and Cochran's Q; patient-level studies only), adjusted (every frequently mutated gene compared between two cohorts by logistic regression on covariates such as MSI and TMB, adjusted beside raw odds ratios; patient-level studies only), actionability (each patient's highest OncoKB therapeutic level and resistance level per cohort or cancer type, and its primary/metastasis concordance), drivernegative (one cohort split into patients with and without an OncoKB driver in a canonical driver list, panel-aware, and every other gene's frequency compared between them), organotropism (which genes' alterations go with metastasis to which organ, per-organ flags, timeline sites or biopsy sites, Cochran–Mantel–Haenszel within cancer types as a gene × site heatmap, plus the cumulative incidence of one site by one gene's status with death competing), molsubtype (a published molecular classifier per patient — the endometrial POLE-ultramutated / MMR-deficient / p53-abnormal / NSMP hierarchy, with not-classifiable patients kept apart — as subtype shares per cohort and by an attribute, and overall survival by subtype), germline (germline carriers per gene among patients sequenced, and how often their tumour has a somatic second hit in the same gene — a mutation or deep deletion — against non-carriers, with optional overall survival by carrier status). Every view's figure is returned as an image. Call describe_dsl first for the grammar and runnable examples. Returns per-cohort counts with plain-language readbacks, view results, caveats you MUST surface to the user, and a link that opens this exact document in the interactive editor.", { document: z.object({}).passthrough() .describe("The Analysis document: {dsl?, study, cohorts: {name: def (may carry splits), ...}, views: [...]}. Grammar and examples: describe_dsl.") diff --git a/scripts/check-method-scripts.ts b/scripts/check-method-scripts.ts index 2136c7c..7c1d4ec 100644 --- a/scripts/check-method-scripts.ts +++ b/scripts/check-method-scripts.ts @@ -20,12 +20,15 @@ import { PYTHON_PINS } from "../lib/method-notebook.ts"; import type { ViewResult } from "../lib/types.ts"; import { RESULT_MARKER, type MethodRecord } from "../lib/method.ts"; import { runExamples, isMain, type ExampleRun } from "./examples-run.ts"; +import { PREVIEW_KINDS } from "../lib/preview-kinds.ts"; /** View kinds without a method record yet, and why. Shrink this list; never grow it quietly. */ const EXEMPT: Record = { oncoprint: "a picture of alterations per patient: nothing is computed beyond the counts its own provenance page shows", hotspots: "a lollipop of mutation counts per position: no statistic", plot: "a scatter/density plot of two axes: no statistic", + // preview kinds carry their Preview badge until their record lands (lib/preview-kinds.ts) + ...Object.fromEntries(Object.entries(PREVIEW_KINDS).map(([k, why]) => [k, "preview: " + why])), }; /** The Python environment the scripts are checked in — the versions the golden fixtures pin. */ diff --git a/test/acquired.test.ts b/test/acquired.test.ts new file mode 100644 index 0000000..ebfefb7 --- /dev/null +++ b/test/acquired.test.ts @@ -0,0 +1,28 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { DrawnResultOf } from "../lib/types.ts"; + +describe("acquired figure", () => { + const gene = (g: string, pairs: number, shared: number, lost: number, acquired: number, q: number) => + ({ gene: g, pairs, shared, lost, acquired, neither: pairs - shared - lost - acquired, p: q, q }); + const v: DrawnResultOf<"acquired"> = { kind: "acquired", cohorts: ["all"], caveat: "seen after, not caused by", + acquired: { anchoredTo: "Treatment [SUBTYPE=Hormone]", preWindowDays: null, minGapDays: 30, sampleEvents: ["Sequencing"], sampleKey: "SAMPLE_ID", measure: "any", + panels: [{ cohort: "all", anchored: 200, dated: 180, withPre: 90, withPost: 70, patients: 40, preDays: 20, postDays: 300, gapDays: { q1: 150, median: 320, q3: 600 }, + genes: [gene("ESR1", 40, 2, 1, 12, 0.01), gene("TP53", 40, 20, 3, 3, 1), gene("NOPE", 0, 0, 0, 0, 1)] }] } }; + test("draws lost and acquired bars, the pairs' spacing and the caveat, and lints clean", () => { + for (const width of [560, 936]) { + const svg = viewToSvg(v, { width })!; + assert.ok(svg.includes("ESR1 — 12 patients acquired")); + assert.ok(svg.includes("ESR1 — 1 patient lost")); + assert.ok(svg.includes("never covered on both samples")); + assert.ok(svg.includes("a median 320 days apart")); + assert.ok(svg.includes("seen after, not caused by")); + assert.deepEqual(lintSvg(svg), []); + } + }); + test("no patient with both samples, no figure", () => { + assert.equal(viewToSvg({ ...v, acquired: { ...v.acquired, panels: [{ ...v.acquired.panels[0], patients: 0 }] } }), null); + }); +}); diff --git a/test/actionability.test.ts b/test/actionability.test.ts new file mode 100644 index 0000000..9983007 --- /dev/null +++ b/test/actionability.test.ts @@ -0,0 +1,13 @@ +// The actionability view's grouping, on fixed inputs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { topGroups } from "../lib/views/actionability.ts"; + +test("the largest groups are kept and the rest pooled into a named Other, counts conserved", () => { + const g = (n: number, l1: number) => ({ sequenced: n, levels: [l1, 0, 0, 0, 0, n - l1], resistance: [1, 0] }); + const { groups, pooled } = topGroups(new Map([["A", g(50, 10)], ["B", g(30, 3)], ["C", g(5, 1)], ["D", g(8, 2)]]), 2); + assert.deepEqual(groups.map((x) => x.label), ["A", "B", "Other (2)"]); + assert.deepEqual(pooled, ["D", "C"]); + assert.equal(groups[2].sequenced, 13); assert.deepEqual(groups[2].levels, [3, 0, 0, 0, 0, 10]); assert.deepEqual(groups[2].resistance, [2, 0]); + assert.equal(topGroups(new Map([["A", g(5, 1)]]), 3).pooled, undefined); +}); diff --git a/test/adjusted.test.ts b/test/adjusted.test.ts new file mode 100644 index 0000000..9f5da25 --- /dev/null +++ b/test/adjusted.test.ts @@ -0,0 +1,44 @@ +// The adjusted view's per-gene fits, on fixed inputs. +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { adjustedFit, covariateKinds, type AdjustedPatient } from "../lib/views/adjusted.ts"; + +// cohort 1 is mostly MSI-high, and MSI-high tumours carry gene X: raw, X is enriched in cohort 1; +// adjusted for MSI, it is not +function patients(): AdjustedPatient[] { + const out: AdjustedPatient[] = []; + const add = (group: 0 | 1, msi: string, n: number, mutated: number, extra: Partial = {}) => { + for (let i = 0; i < n; i++) out.push({ group, covers: null, genes: new Set(i < mutated ? ["X"] : []), values: [msi], ...extra }); + }; + add(1, "MSI-H", 60, 30); add(1, "MSS", 40, 4); + add(0, "MSI-H", 20, 10); add(0, "MSS", 180, 18); + return out; +} + +describe("adjustedFit", () => { + test("a confounded gene: raw odds ratio far from 1, adjusted near 1", () => { + const pts = patients(); + const { rows, failed } = adjustedFit(pts, ["X"], ["MSI"], covariateKinds(pts, 1)); + assert.equal(failed.length, 0); + const g = rows[0]; + assert.deepEqual(g.altered, [34, 28]); assert.deepEqual(g.profiled, [100, 200]); + near(g.rawOr!, (34 * 172) / (66 * 28)); + assert.ok(g.fisherP < 1e-3, "raw p " + g.fisherP); + near(g.or, 1, 1e-6); + }); + test("a patient whose panels do not cover the gene is left out of it, never wild type", () => { + const pts = [...patients(), ...Array.from({ length: 50 }, () => ({ group: 1 as const, covers: new Set(["Y"]), genes: new Set(), values: ["MSS"] }))]; + const { rows } = adjustedFit(pts, ["X"], ["MSI"], covariateKinds(pts, 1)); + assert.deepEqual(rows[0].profiled, [100, 200]); + }); + test("a gene mutated in no patient of one cohort cannot be fit and is listed", () => { + const pts = patients().map((r) => r.group === 0 ? { ...r, genes: new Set() } : r); + const { rows, failed } = adjustedFit(pts, ["X"], [], []); + assert.equal(rows.length, 0); + assert.equal(failed[0].gene, "X"); + }); + test("numbers are numeric covariates, anything else categories", () => { + assert.deepEqual(covariateKinds([{ values: ["1.5", "a"] }, { values: ["2", "b"] }], 2), ["num", "cat"]); + }); +}); +const near = (a: number, b: number, tol = 1e-9) => assert.ok(Math.abs(a - b) < tol, a + " is not " + b); diff --git a/test/boundary.test.ts b/test/boundary.test.ts index d5a52d3..17931e3 100644 --- a/test/boundary.test.ts +++ b/test/boundary.test.ts @@ -290,6 +290,8 @@ describe("federation SQL returns aggregates, never identifiers", () => { const LAB = { eventType: "Lab_Test", testKey: "TEST", test: "PSA", resultKey: "RESULT" }; const LAB_ANCHOR = { eventType: "Treatment", key: "SUBTYPE", value: "Hormone", occurrence: "FIRST" as const }; const LAB_OPTS = { baselineDays: 90, fromDays: -84, toDays: 364, binDays: 28 }; + const ACQ = { anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Hormone" }, sampleEvents: ["Sample acquisition", "Sequencing"], sampleKey: "SAMPLE_ID", preWindowDays: null, minGapDays: 30 }; + const CTD = { attribute: "SAMPLE_CLASS", plasma: ["cfDNA", "ctDNA"], tissue: ["Tumor"], plasmaPanels: [] as string[], withinDays: null as number | null, sampleEvents: ["Sequencing"], sampleKey: "SAMPLE_ID" }; const BUILDERS: Record string> = { // Plots: an axis selects (id, value) per case, but every builder here groups the id away. // If one ever projected a case id, this suite is what would stop it reaching a peer. @@ -379,6 +381,26 @@ describe("federation SQL returns aggregates, never identifiers", () => { "pairedCountsSql:scan": () => federation.pairedCountsSql(cohortSql, "x", "db", null, { attribute: "IMPACT_PRE_POST", a: ["PRE-TX"], b: ["POST-TX"] }, "v7.5", "driver"), "pairedCountsSql:scan:any": () => federation.pairedCountsSql(cohortSql, "x", "db", null, { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }, null, "any"), "pairedPatientsSql": () => federation.pairedPatientsSql(cohortSql, "x", "db", { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }), + // acquired: `pick`/`ps` hold each patient's before and after sample; the outer SELECTs count them per gene or along the funnel + "acquiredCountsSql": () => federation.acquiredCountsSql(cohortSql, "x", "db", ["TP53", "ESR1"], ACQ, "v7.5", "driver"), + "acquiredCountsSql:scan-any": () => federation.acquiredCountsSql(cohortSql, "x", "db", null, { ...ACQ, preWindowDays: 365 }, null, "any"), + "acquiredFunnelSql": () => federation.acquiredFunnelSql(cohortSql, "x", "db", ACQ), + // ctdna: `classed`/`sides`/`ps` hold samples by kind; the outer SELECTs count them per gene, along the funnel, or per (detected, months, dead) + "ctdnaCountsSql": () => federation.ctdnaCountsSql(cohortSql, "x", "db", ["EGFR"], CTD, "v7.5", "driver"), + "ctdnaCountsSql:dated-panels": () => federation.ctdnaCountsSql(cohortSql, "x", "db", null, { ...CTD, plasmaPanels: ["ACCESS129"], withinDays: 90 }, null, "any"), + "ctdnaFunnelSql": () => federation.ctdnaFunnelSql(cohortSql, "x", "db", CTD, "v7.5", "driver"), + // exclusivity: `sp`/`altc`/`per` hold samples; the outer SELECTs count them per (panel signature, stratum, burden, mask) or per gene + "exclusivityGroupsSql": () => federation.exclusivityGroupsSql(cohortSql, "x", "db", ["KRAS", "EGFR"], "CANCER_TYPE", "v7.5", "driver"), + "exclusivityGroupsSql:any": () => federation.exclusivityGroupsSql(cohortSql, "x", "db", ["KRAS", "EGFR", "TP53"], null, null, "any"), + "exclusivityColumnsSql": () => federation.exclusivityColumnsSql(cohortSql, "x", "db", null, null, "any"), + "exclusivityCoverageSql": () => federation.exclusivityCoverageSql(cohortSql, "x", "db"), + // instability: `parts`/`arms`/`calls`/`per` hold samples' segments and calls; the outer SELECTs are histograms and per-arm counts + "instabilityScoresSql": () => federation.instabilityScoresSql(cohortSql, "x", "db", { threshold: 0.2, armShare: 0.5, includeX: false }), + "instabilityArmsSql": () => federation.instabilityArmsSql(cohortSql, "x", "db", { threshold: 0.3, armShare: 0.8, includeX: true }), + "ctdnaCountsSql:ch": () => federation.ctdnaCountsSql(cohortSql, "x", "db", ["EGFR"], { ...CTD, ch: { genes: ["DNMT3A", "TET2"], normalMinAlt: 2, normalMinRatio: 0.3 } }, "v7.5", "driver"), + "ctdnaFunnelSql:ch-genes-only": () => federation.ctdnaFunnelSql(cohortSql, "x", "db", { ...CTD, ch: { genes: ["DNMT3A"], normalMinAlt: null, normalMinRatio: 0.3 } }, null, "any"), + "ctdnaChRemovedSql": () => federation.ctdnaChRemovedSql(cohortSql, "x", "db", { ...CTD, ch: { genes: ["DNMT3A"], normalMinAlt: 2, normalMinRatio: 0.3 } }), + "ctdnaSurvivalSql": () => federation.ctdnaSurvivalSql(cohortSql, "x", "db", { ...CTD, withinDays: 30 }, null, "any"), "sampleAttributeValuesSql": () => federation.sampleAttributeValuesSql(cohortSql, "x", "db", "SAMPLE_TYPE"), // clonality: `calls` is per (gene, sample); only per-gene counts and CCF bins leave "clonalityGenesSql": () => federation.clonalityGenesSql(cohortSql, "x", "db", ["TP53"], "v7.5", "driver", true), @@ -389,6 +411,14 @@ describe("federation SQL returns aggregates, never identifiers", () => { // incidence: `per`/`cls` are per patient; the outer SELECT groups them into (kind, entry, t, cause) tallies "incidenceCountsSql": () => federation.incidenceCountsSql(cohortSql, "x", "db", { eventType: "Diagnosis", attrs: [{ key: "TUMOR_SITE", value: "CNS/Brain" }] }, null), "incidenceCountsSql:anchored": () => federation.incidenceCountsSql(cohortSql, "x", "db", { eventType: "Status" }, { eventType: "Treatment", key: "SUBTYPE", value: "Chemo", occurrence: "FIRST" }), + // txendpoints: `per`/`cls` are per patient; the outer SELECT groups them into (kind, entry, t, ev) tallies + "txEndpointCountsSql:ttnt": () => federation.txEndpointCountsSql(cohortSql, "x", "db", { endpoint: "ttnt", anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" }, lines: { gap: 90 } }), + "txEndpointCountsSql:tot": () => federation.txEndpointCountsSql(cohortSql, "x", "db", { endpoint: "tot", anchor: { eventType: "Treatment", key: "AGENT", value: "PEMBROLIZUMAB", occurrence: "LAST" }, lines: { gap: 60, classes: "all" }, ongoingDays: 30 }), + "txEndpointCountsSql:rwpfs": () => federation.txEndpointCountsSql(cohortSql, "x", "db", { endpoint: "rwpfs", anchor: { eventType: "Treatment" }, lines: { gap: 90 }, + progression: { eventType: "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"], probabilityKey: "NLP_PROGRESSION_PROBABILITY", minProbability: 0.8 } }), + "txEndpointCountsSql:os": () => federation.txEndpointCountsSql(cohortSql, "x", "db", { endpoint: "os", anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" }, lines: { gap: 90 } }), + // multistate: `per`/`cls` are per patient; the outer SELECT groups them into (prevalent, tp, te, dead, flags) tallies + "multistateCountsSql": () => federation.multistateCountsSql(cohortSql, "x", "db", { eventType: "Diagnosis", key: "PROGRESSION", values: ["Y", "Yes"], probabilityKey: "NLP_PROGRESSION_PROBABILITY", minProbability: 0.8 }), // labs: per-patient % changes stay inside; the course and best-response queries return histograms "labCourseSql": () => federation.labCourseSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS), "labBestSql": () => federation.labBestSql(cohortSql, "x", "db", LAB, LAB_ANCHOR, LAB_OPTS, 50), @@ -397,7 +427,45 @@ describe("federation SQL returns aggregates, never identifiers", () => { // run only by PatientLevelResolver.viewRows: one row per patient, values only "riskmodelRowsSql": () => federation.riskmodelRowsSql(cohortSql, "x", "db", { endpoint: "OS", strata: "CANCER_TYPE", driverPin: "v7", covariates: ["AGE", "STAGE"], anchor: { eventType: "Treatment", key: "SUBTYPE", value: "Immuno" } }), + // run only by PatientLevelResolver.viewRows: one row per patient, panels, genes and covariate values only + "adjustedRowsSql": () => federation.adjustedRowsSql(cohortSql, "x", "db", { covariates: ["MSI_TYPE", "TMB_NONSYNONYMOUS"], driverPin: "v7.5" }), + "adjustedRowsSql:no-covariates": () => federation.adjustedRowsSql(cohortSql, "x", "db", { covariates: [] }), + // actionability: per-sample and per-patient levels stay in CTEs; patients are counted per level + "actionLevelsSql": () => federation.actionLevelsSql(cohortSql, "x", "db", "v7.5", "CANCER_TYPE"), + "actionLevelsSql:ungrouped": () => federation.actionLevelsSql(cohortSql, "x", "db", "v7.5", null), + "actionConcordanceSql": () => federation.actionConcordanceSql(cohortSql, "x", "db", "v7.5", { attribute: "SAMPLE_TYPE", a: ["Primary"], b: ["Metastasis"] }), + "actionStoreTypesSql": () => federation.actionStoreTypesSql("x", "db", "v7.5"), + // drivernegative: per-patient groups stay in CTEs; patients are counted per group and gene + "dnegGroupsSql": () => federation.dnegGroupsSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS", "ALK"]), + "dnegAlteredSql": () => federation.dnegAlteredSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS"], "mutation", false), + "dnegAlteredSql:sv-drivers": () => federation.dnegAlteredSql(cohortSql, "x", "db", "v7.5", ["EGFR"], "sv", true), + "dnegProfiledSql": () => federation.dnegProfiledSql(cohortSql, "x", "db", "v7.5", ["EGFR", "KRAS"], "mutation"), + // organotropism: per-patient sites, panels and alterations stay in CTEs; the outer SELECTs count + "orgSourcesSql": () => federation.orgSourcesSql(cohortSql, "x", "db", { prefix: "DMETS_DX_", eventType: "Diagnosis", key: "TUMOR_SITE", attribute: "METASTATIC_SITE" }), + "orgSitesSql:flags": () => federation.orgSitesSql(cohortSql, "x", "db", { kind: "flags", prefix: "DMETS_DX_" }), + "orgSitesSql:timeline": () => federation.orgSitesSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }), + "orgSitesSql:sample": () => federation.orgSitesSql(cohortSql, "x", "db", { kind: "sample", attribute: "METASTATIC_SITE" }), + "orgOtherValuesSql": () => federation.orgOtherValuesSql(cohortSql, "x", "db", "METASTATIC_SITE"), + "orgTopGenesSql": () => federation.orgTopGenesSql(cohortSql, "x", "db", { alteration: "mutation", driverPin: "v7.5" }, 12), + "orgCountsSql": () => federation.orgCountsSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }, { genes: ["EGFR", "TP53"], alteration: "mutation", driverPin: null }, "CANCER_TYPE"), + // molsubtype: each patient's class stays in CTEs; patients are counted per class, group and survival time + "molCountsSql": () => federation.molCountsSql(cohortSql, "x", "db", { poleHotspots: ["P286R", "V411L"], msi: { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"] }, p53Pin: "v7.5", by: "ANCESTRY_LABEL" }), + "molCountsSql:score": () => federation.molCountsSql(cohortSql, "x", "db", { poleHotspots: ["P286R"], msi: { attribute: "MSI_SCORE", threshold: 10, stableBelow: 3 }, p53Pin: null, by: null }), + "molSurvivalSql": () => federation.molSurvivalSql(cohortSql, "x", "db", { poleHotspots: ["P286R"], msi: { attribute: "MSI_TYPE", high: ["Instable"], stable: ["Stable"] }, p53Pin: "v7.5", by: null }), + "molMsiAttributesSql": () => federation.molMsiAttributesSql(cohortSql, "x", "db"), + // germline: carriers, somatic hits and panels stay in CTEs; patients are counted per gene and combination + "germCarriersSql": () => federation.germCarriersSql(cohortSql, "x", "db"), + "germCountsSql": () => federation.germCountsSql(cohortSql, "x", "db", ["BRCA1", "BRCA2"]), + "germSurvivalSql": () => federation.germSurvivalSql(cohortSql, "x", "db", "BRCA2"), + "orgIncidenceSql": () => federation.orgIncidenceSql(cohortSql, "x", "db", { kind: "timeline", eventType: "Diagnosis", key: "TUMOR_SITE" }, { genes: ["EGFR"], alteration: "mutation", driverPin: "v7.5" }, true, "CNS/Brain"), "riskmodelPanelGenesSql": () => federation.riskmodelPanelGenesSql("x", "db"), + // run only by PatientLevelResolver.viewRows: one row of values per patient, no identifier + "cutpointRowsSql": () => federation.cutpointRowsSql(cohortSql, "x", "db", { attribute: "TMB_NONSYNONYMOUS", months: "OS_MONTHS", status: "OS_STATUS" }), + // run only by PatientLevelResolver.viewRows: one row of genotype, LOH, endpoint and covariate values per patient, no identifier + "hlaRowsSql": () => federation.hlaRowsSql(cohortSql, "x", "db", { loci: ["A", "B", "C"].map((l) => ({ first: ["HLA-" + l + "1"], second: ["HLA-" + l + "2"], pair: ["HLA_" + l] })), + fields: 2, lohAttributes: ["HLA_A1_LOH"], lohValues: ["Loss"], months: "OS_MONTHS", status: "OS_STATUS", covariates: ["TMB_SCORE", "CANCER_TYPE"] }), + "hlaAttributesSql": () => federation.hlaAttributesSql(cohortSql, "x", "db"), + "cutpointRowsSql:stratified": () => federation.cutpointRowsSql(cohortSql, "x", "db", { attribute: "TMB_NONSYNONYMOUS", months: "OS_MONTHS", status: "OS_STATUS", stratum: "CANCER_TYPE" }), "labTestsSql": () => federation.labTestsSql(cohortSql, "x", "db", "Lab_Test", "TEST"), // cancertypes: `alt`/`per`/`covered` hold samples; the outer SELECT counts them per group "cancerTypesSql": () => federation.cancerTypesSql(cohortSql, "x", "db", ["KRAS", "NRAS"], "CANCER_TYPE", "v7.5", "driver"), diff --git a/test/ctdna.test.ts b/test/ctdna.test.ts new file mode 100644 index 0000000..3e9f3fc --- /dev/null +++ b/test/ctdna.test.ts @@ -0,0 +1,54 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import { wilsonInterval } from "../lib/stats.ts"; +import type { DrawnResultOf } from "../lib/types.ts"; +import { CH_GENES } from "../lib/views/ctdna.ts"; + +describe("wilsonInterval", () => { + test("matches statsmodels' proportion_confint(method='wilson')", () => { + const w = wilsonInterval(8, 10)!; + assert.ok(Math.abs(w.low - 0.4901625) < 1e-6 && Math.abs(w.high - 0.9433178) < 1e-6); + const z = wilsonInterval(0, 5)!; + assert.equal(z.low, 0); + assert.ok(Math.abs(z.high - 0.4344825) < 1e-6); + assert.equal(wilsonInterval(0, 0), null); + }); +}); + +describe("ctdna figure", () => { + const curve = (label: string, n: number) => ({ label, n, events: n - 1, median: 10, points: [{ t: 5, s: 0.7 }, { t: 10, s: 0.5 }, { t: 20, s: 0.2 }] }); + const v: DrawnResultOf<"ctdna"> = { kind: "ctdna", cohorts: ["all"], caveat: "tissue is the reference", + ctdna: { attribute: "SAMPLE_CLASS", plasma: ["cfDNA"], tissue: ["Tumor"], plasmaPanels: [], withinDays: null, measure: "any", + panels: [{ cohort: "all", plasmaPatients: 100, tissuePatients: 80, patients: 60, plasmaSamples: 70, tissueSamples: 65, detected: 66, detectedLow: 0.56, detectedHigh: 0.75, + genes: [{ gene: "EGFR", pairs: 60, both: 20, tissueOnly: 10, plasmaOnly: 2, neither: 28, ppa: 2 / 3, ppaLow: 0.49, ppaHigh: 0.81, concordance: 0.8 }], + survival: { curves: [curve("ctDNA detected", 66), curve("not detected", 34)], logrank: { chi2: 4, p: 0.045, df: 1, observed: [1, 1], expected: [1, 1], variance: 1, strata: 1 }, missing: 0 } }] } }; + test("draws the bars, the agreement and the survival panel, and lints clean", () => { + for (const width of [560, 936]) { + const svg = viewToSvg(v, { width })!; + assert.ok(svg.includes("EGFR — 10 patients in tissue only")); + assert.ok(svg.includes("67% [49%–81%]")); + assert.ok(svg.includes("Overall survival by ctDNA detected")); + assert.deepEqual(lintSvg(svg), []); + } + }); + test("says why survival is not drawn", () => { + const svg = viewToSvg({ ...v, ctdna: { ...v.ctdna, panels: [{ ...v.ctdna.panels[0], survival: null, survivalWhy: "no plasma patient has OS_MONTHS" }] } })!; + assert.ok(svg.includes("no plasma patient has OS_MONTHS")); + }); + test("with the CH filter, says what it removed and the unfiltered numbers, and lints clean", () => { + const pn = v.ctdna.panels[0]; + const ch = { calls: 200, withNormal: 200, byGene: 12, byNormal: 5, removed: 16, patients: 9, genes: [{ gene: "DNMT3A", calls: 10, patients: 6, byGene: 10, byNormal: 1 }], + before: { detected: 70, detectedLow: 0.6, detectedHigh: 0.79, genes: [{ gene: "EGFR", both: 20, plasmaOnly: 3, ppa: 2 / 3 }], survival: { curves: [], p: 0.01 } } }; + for (const width of [560, 936]) { + const svg = viewToSvg({ ...v, ctdna: { ...v.ctdna, chFilter: { genes: CH_GENES, normalMinAlt: 2, normalMinRatio: 0.3 }, panels: [{ ...pn, ch }] } }, { width })!; + assert.ok(svg.includes("Clonal-haematopoiesis filter removed 16 of 200")); + assert.ok(svg.includes("plasma-only unfiltered")); + assert.deepEqual(lintSvg(svg), []); + } + }); + test("TP53 is not on the default CH gene list (a tumour driver as often as a CH gene)", () => { + assert.ok(CH_GENES.includes("DNMT3A") && !CH_GENES.includes("TP53")); + }); +}); diff --git a/test/cutpoint.test.ts b/test/cutpoint.test.ts new file mode 100644 index 0000000..d600aa7 --- /dev/null +++ b/test/cutpoint.test.ts @@ -0,0 +1,46 @@ +// The cut-point view's scan: it finds a planted threshold, corrects for the search, and agrees +// with the ordinary log-rank test at the cut it picks. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { scanCutpoints, CUT_MAX_CANDIDATES, type CutRec } from "../lib/views/cutpoint.ts"; +import { seededRandom } from "../lib/stats.ts"; + +const cohort = (n: number, hazard: (v: number) => number, seed: number): CutRec[] => { + const rand = seededRandom(seed); + return Array.from({ length: n }, (_, i) => { + const v = i % 50; + const t = -Math.log(1 - rand()) / hazard(v); + return { v, t: Math.min(t, 30), ev: t < 30 ? 1 : 0 }; + }); +}; + +test("a planted threshold at 30 is found, and its corrected p is small", () => { + const s = scanCutpoints(cohort(800, (v) => v > 30 ? 0.2 : 0.05, 3), 300, 1)!; + assert.ok(s.cut >= 28 && s.cut <= 32, "cut " + s.cut); + assert.ok(s.permP < 0.01); + assert.ok(s.hr!.hr > 2); +}); + +test("under a modest effect the standardized score statistic is the log-rank chi-squared at the cut, up to its variance estimate", () => { + const s = scanCutpoints(cohort(800, (v) => v > 30 ? 0.075 : 0.05, 4), 200, 1)!; + assert.ok(Math.abs(s.maxStat - s.naiveChi2) / s.naiveChi2 < 0.1, s.maxStat + " vs " + s.naiveChi2); +}); + +test("without an effect the best cut is not significant once the search is corrected for", () => { + const s = scanCutpoints(cohort(800, () => 0.1, 5), 300, 1)!; + assert.ok(s.permP > 0.05, "permP " + s.permP); + assert.ok(s.permP >= s.naiveP, "the corrected p is never below the naive one here"); +}); + +test("candidates stay inside the 10th–90th percentile window and under the cap; seeded", () => { + const recs = Array.from({ length: 1000 }, (_, i) => ({ v: i, t: (i * 7919) % 97 + 1, ev: i % 3 ? 1 : 0 })); + const a = scanCutpoints(recs, 200, 9)!, b = scanCutpoints(recs, 200, 9)!; + assert.equal(a.candidates.length, CUT_MAX_CANDIDATES); + assert.ok(a.candidates.every((c) => c.cut >= 99 && c.cut <= 899)); + assert.equal(a.permP, b.permP); +}); + +test("too few patients, or one value, is no scan", () => { + assert.equal(scanCutpoints(cohort(10, () => 0.1, 1), 100, 1), null); + assert.equal(scanCutpoints(Array.from({ length: 50 }, (_, i) => ({ v: 1, t: i + 1, ev: 1 })), 100, 1), null); +}); diff --git a/test/drivernegative.test.ts b/test/drivernegative.test.ts new file mode 100644 index 0000000..17f7f0c --- /dev/null +++ b/test/drivernegative.test.ts @@ -0,0 +1,23 @@ +// The driver-negative view's per-gene tests, on fixed inputs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { dnegTest } from "../lib/views/drivernegative.ts"; +import { fisherExact } from "../lib/stats.ts"; + +test("per gene: whole-exome patients join every gene's denominator, Fisher and BH, untested genes counted", () => { + const altered = new Map([["NTRK1", [12, 1]], ["TP53", [40, 60]], ["RARE", [1, 1]]]); + const profiled = new Map([["NTRK1", [90, 190]], ["TP53", [90, 190]]]); + const { tested, untested } = dnegTest(altered, profiled, [10, 10], 5); + assert.equal(untested, 1); + const nt = tested.find((g) => g.gene === "NTRK1")!; + assert.deepEqual(nt.profiled, [100, 200]); + assert.equal(nt.p, fisherExact(12, 88, 1, 199)); + assert.ok(nt.log2Ratio! > 0 && nt.q >= nt.p); + assert.equal(tested[0].gene, "NTRK1"); +}); + +test("an off-panel call counts as measured, never as more altered than profiled", () => { + const { tested } = dnegTest(new Map([["X", [6, 0]]]), new Map(), [0, 4], 5); + assert.deepEqual(tested[0].profiled, [6, 4]); + assert.equal(tested[0].log2Ratio, null); +}); diff --git a/test/exclusivity.test.ts b/test/exclusivity.test.ts new file mode 100644 index 0000000..7f0d305 --- /dev/null +++ b/test/exclusivity.test.ts @@ -0,0 +1,56 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { poissonBinomialTails } from "../lib/stats.ts"; +import { fitBackground } from "../lib/views/exclusivity.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { DrawnResultOf } from "../lib/types.ts"; + +describe("poissonBinomialTails", () => { + test("p = ½, ½, ⅕ by hand: P(0)=.2, P(1)=.45, P(2)=.3, P(3)=.05", () => { + const t = poissonBinomialTails([{ n: 2, p: 0.5 }, { n: 1, p: 0.2 }], 1); + assert.ok(Math.abs(t.lower - 0.65) < 1e-12 && Math.abs(t.upper - 0.8) < 1e-12 && t.exact); + assert.ok(Math.abs(t.mean - 1.2) < 1e-12); + const three = poissonBinomialTails([{ n: 2, p: 0.5 }, { n: 1, p: 0.2 }], 3); + assert.ok(Math.abs(three.upper - 0.05) < 1e-12 && Math.abs(three.lower - 1) < 1e-12); + }); + test("one group is the binomial; the approximation lands near the exact tail", () => { + const ex = poissonBinomialTails([{ n: 1000, p: 0.3 }], 280), ap = poissonBinomialTails([{ n: 1000, p: 0.3 }], 280, 1); + assert.ok(Math.abs(ex.lower - 0.08858) < 1e-4, String(ex.lower)); + assert.ok(!ap.exact && Math.abs(ap.lower - ex.lower) < 1e-3 && Math.abs(ap.upper - ex.upper) < 1e-3); + }); +}); + +describe("fitBackground", () => { + test("matches every row and column margin, panel-aware", () => { + const rows = [{ sig: "A", r: 1, n: 10 }, { sig: "A", r: 2, n: 5 }, { sig: "A", r: 0, n: 5 }, { sig: "B", r: 1, n: 4 }]; + const cover = new Map([["A", new Set(["g1", "g2", "g3"])], ["B", new Set(["g1", "g2"])]]); + const cols = new Map([["g1", 12], ["g2", 8], ["g3", 4]]); + const f = fitBackground(rows, cols, cover); + assert.ok(f.converged); + const p = (i: number, g: string) => 1 / (1 + Math.exp(-(f.a[i] + f.b.get(g)!))); + for (const [g, c] of cols) { + const sum = rows.reduce((s, r, i) => s + (cover.get(r.sig)!.has(g) && r.r > 0 ? r.n * p(i, g) : 0), 0); + assert.ok(Math.abs(sum - c) < 1e-2, g + " " + sum); + } + assert.ok(Math.abs(p(0, "g1") + p(0, "g2") + p(0, "g3") - 1) < 1e-3); + assert.equal(f.a[2], -Infinity); + }); +}); + +describe("exclusivity figure", () => { + const pair = (a: string, b: string, qE: number, qC: number) => ({ a, b, profiled: 100, both: 2, expected: 10, pExclusive: qE, qExclusive: qE, pCooccur: qC, qCooccur: qC, exact: true, + onlyA: 20, onlyB: 20, neither: 58, fisherP: 0.01, fisherQ: 0.02, log2Odds: -1.5 }); + const v: DrawnResultOf<"exclusivity"> = { kind: "exclusivity", cohorts: ["NSCLC"], caveat: "a pattern, not a mechanism", + exclusivity: { cohort: "NSCLC", genes: ["KRAS", "EGFR", "TP53"], stratifyBy: null, strata: [{ name: "all", samples: 100 }], measure: "any", samples: 100, matrixGenes: 300, + iterations: 4, converged: true, maxResidual: 1e-5, pairs: [pair("KRAS", "EGFR", 1e-9, 1), pair("KRAS", "TP53", 1, 0.03)], unassessable: ["EGFR/TP53"] } }; + test("draws the triangle, the never-profiled cell and the table, and lints clean", () => { + for (const width of [560, 936]) { + const svg = viewToSvg(v, { width })!; + assert.ok(svg.includes("TP53 / EGFR — never profiled together")); + assert.ok(svg.includes("1e-9 exclusive")); + assert.ok(svg.includes("0.03 co-occurring")); + assert.deepEqual(lintSvg(svg), []); + } + }); +}); diff --git a/test/fixtures/example-counts.json b/test/fixtures/example-counts.json index 6de6cc0..4294732 100644 --- a/test/fixtures/example-counts.json +++ b/test/fixtures/example-counts.json @@ -1,7 +1,7 @@ { "note": "Expected cohort counts for every example in lib/dsl-reference.ts, asserted by npm run docs:check. Regenerate with `npm run docs:counts` ONLY after reviewing why a number moved.", "oncokbPin": "v7.5", - "generated": "2026-10-20", + "generated": "2026-10-23", "examples": { "Lung adenocarcinoma: a four-view workup": { "EGFR-mutant NSCLC": 1893, @@ -147,6 +147,34 @@ "Logistic regression: does APC status predict MSI-high status?": { "all patients": 471 }, + "Pan-cancer forest: TP53 mutation and survival in 30 cancer types": { + "all patients": 48179 + }, + "Adjusted enrichment: early-onset colorectal cancer, adjusted for MSI and TMB": { + "Early-onset CRC": 1699, + "Average-onset CRC": 3149 + }, + "Clinical actionability: OncoKB levels across 50,000 tumours, and primary against metastasis": { + "all patients": 48179 + }, + "Driver-negative lung cancer: what is mutated when no canonical driver is": { + "NSCLC": 7809 + }, + "Metastatic organotropism: which mutations go with spread to which organ (MSK-MET)": { + "all patients": 25775 + }, + "Brain metastasis in lung cancer by EGFR, from dated tumour sites": { + "NSCLC": 7809 + }, + "Endometrial cancer molecular subtypes by genetic ancestry (POLE, MMRd, p53abn, NSMP)": { + "Endometrial": 2373 + }, + "The endometrial classifier against TCGA's own molecular subtypes": { + "UCEC": 529 + }, + "Germline carriers in metastatic prostate cancer, and their tumours' second hits": { + "mCRPC": 429 + }, "splitBy: one cohort, auto-split into an arm per receptor subtype": { "all patients": 1756 }, @@ -204,6 +232,23 @@ "Before and after treatment, pooled across studies that label it differently": { "before and after treatment": 481 }, + "Acquired under therapy: the sample before a treatment against the sample after it": { + "all patients": 172 + }, + "ctDNA against tissue: what a blood test sees of the tumour, and what detecting it says": { + "all patients": 1127 + }, + "Mutual exclusivity that accounts for each tumour's burden (DISCOVER)": { + "NSCLC": 1567 + }, + "Genomic instability by cancer type: aneuploidy score, FGA and arm-level changes": { + "Colorectal": 4993, + "Breast": 5803, + "Pancreatic": 2729 + }, + "ctDNA with clonal haematopoiesis filtered out: MSK-ACCESS plasma against tissue": { + "all patients": 617 + }, "Clonality, LOH and genome doubling from FACETS": { "Colorectal": 4993, "Breast": 5803 @@ -213,6 +258,30 @@ "Breast": 5368, "Colorectal": 5543 }, + "Months of life gained on immunotherapy with a high tumour mutational burden, with landmarks": { + "TMB < 10": 1201, + "TMB ≥ 10": 460 + }, + "From progression to death in colorectal cancer, by stage": { + "Colorectal, stage 1-3": 3171, + "Colorectal, stage 4": 2372 + }, + "Real-world PFS on immunotherapy in lung cancer, by KRAS status": { + "NSCLC, KRAS mutant": 2194, + "NSCLC, KRAS wild-type": 5540 + }, + "Which mutations change survival on immunotherapy in lung cancer?": { + "NSCLC": 7809 + }, + "A tumour mutational burden cut-point for survival on immunotherapy": { + "all patients": 1661 + }, + "HLA class I homozygosity and HLA loss in lung cancer, and survival": { + "NSCLC": 6633 + }, + "HLA class I homozygosity and survival on checkpoint blockade in melanoma": { + "melanoma on checkpoint blockade": 212 + }, "PSA response to hormone therapy, by TP53 status": { "Prostate, TP53 mutant": 849, "Prostate, TP53 wild-type": 2284 @@ -220,6 +289,10 @@ "A survival risk model from mutated genes in lung cancer": { "NSCLC": 7809 }, + "A lung cancer risk model, validated on another study": { + "NSCLC (MSK-IMPACT 2017)": 1567, + "NSCLC (MSK-CHORD)": 7809 + }, "Custom data: a curated biopsy site on every sample": { "all patients": 93, "CNS met": 8 diff --git a/test/forest.test.ts b/test/forest.test.ts new file mode 100644 index 0000000..e9a0a49 --- /dev/null +++ b/test/forest.test.ts @@ -0,0 +1,56 @@ +// The forest view's pooling and row building, on fixed inputs. +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { dersimonianLaird } from "../lib/stats.ts"; +import { forestFit, type ForestPatient } from "../lib/views/forest.ts"; + +const near = (a: number, b: number, tol = 1e-9) => assert.ok(Math.abs(a - b) < tol, a + " is not " + b); + +describe("dersimonianLaird", () => { + // reference values computed by hand from the DerSimonian–Laird formulas + test("fixed and random effects, tau², Q, I²", () => { + const m = dersimonianLaird([{ beta: 0.2, se: 0.1 }, { beta: 0.5, se: 0.2 }, { beta: -0.1, se: 0.15 }]); + near(m.fixed.beta, 0.16557377049180327); near(m.fixed.se, 0.07682212795973759); + near(m.q, 6.049180327868853); near(m.tau2, 0.04258620689655173); + near(m.random.beta, 0.17899073644682223); near(m.random.se, 0.1466641616170751); + near(m.i2, 0.6693766937669376); near(m.qP, 0.04857772609501106, 1e-6); + near(m.weights.reduce((a, b) => a + b, 0), 1); + }); + test("homogeneous strata: tau² is 0 and the two pools agree", () => { + const m = dersimonianLaird([{ beta: 0.3, se: 0.1 }, { beta: 0.3, se: 0.2 }]); + assert.equal(m.tau2, 0); assert.equal(m.i2, 0); near(m.random.beta, m.fixed.beta); + }); + test("refuses fewer than two estimates or a zero se", () => { + assert.throws(() => dersimonianLaird([{ beta: 1, se: 0.1 }])); + assert.throws(() => dersimonianLaird([{ beta: 1, se: 0.1 }, { beta: 1, se: 0 }])); + }); +}); + +describe("forestFit", () => { + // deterministic patients: in stratum A the exposed die sooner; B is small and pools into Other + const make = (stratum: string, n: number, hrShift: number): ForestPatient[] => Array.from({ length: n }, (_, i) => { + const exposed = (i % 2) as 0 | 1; + return { t: 1 + ((i * 7) % 23) / (exposed ? hrShift : 1), event: i % 3 === 0 ? 0 : 1, exposed, stratum, values: [] }; + }); + const opts = { minPatients: 30, minEvents: 10, maxStrata: 30, small: "pool" as const }; + test("one row per large stratum, the small ones pooled into a disclosed Other", () => { + const f = forestFit([...make("A", 80, 2), ...make("C", 60, 1.5), ...make("B", 12, 1), ...make("D", 12, 1), ...make("E", 10, 1)], [], [], opts); + assert.deepEqual(f.rows.map((r) => r.label), ["A", "C", "Other (3 strata)"]); + assert.deepEqual(f.rows[2].pooled, ["B", "D", "E"]); + assert.ok(f.rows[0].hr > 1, "the exposed die sooner in A"); + assert.ok(f.random && f.fixed); + near(f.rows.reduce((a, r) => a + r.weight, 0), 1); + }); + test("drop lists the small strata instead, with the reason", () => { + const f = forestFit([...make("A", 80, 2), ...make("C", 60, 1.5), ...make("B", 12, 1)], [], [], { ...opts, small: "drop" }); + assert.deepEqual(f.rows.map((r) => r.label), ["A", "C"]); + assert.equal(f.left[0].label, "B"); assert.match(f.left[0].reason, /fewer than 30 patients/); + }); + test("a stratum without exposed patients is left out, not fit", () => { + const none = make("Z", 40, 1).map((r) => ({ ...r, exposed: 0 as const })); + const f = forestFit([...make("A", 80, 2), ...none], [], [], opts); + assert.deepEqual(f.rows.map((r) => r.label), ["A"]); + assert.equal(f.left[0].reason, "no exposed patient"); + assert.equal(f.random, null); + }); +}); diff --git a/test/germline.test.ts b/test/germline.test.ts new file mode 100644 index 0000000..2ee1786 --- /dev/null +++ b/test/germline.test.ts @@ -0,0 +1,25 @@ +// The germline view's per-gene counting, on fixed inputs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { germAdd } from "../lib/views/germline.ts"; + +const blank = () => ({ sequenced: 0, carriers: 0, outsidePanel: 0, carrierSomatic: 0, carrierCnaProfiled: 0, carrierDeletion: 0, carrierEither: 0, + noncarriers: 0, noncarrierSomatic: 0, noncarrierCnaProfiled: 0, noncarrierDeletion: 0, noncarrierEither: 0 }); + +test("carriers and non-carriers split, second hits counted once, deletions only where copy number was profiled", () => { + const c = blank(); + germAdd(c, { mp: 1, carrier: 1, som: 1, cp: 1, del: 1, n: 2 }); + germAdd(c, { mp: 1, carrier: 1, som: 0, cp: 0, del: 0, n: 3 }); + germAdd(c, { mp: 1, carrier: 0, som: 0, cp: 1, del: 1, n: 4 }); + germAdd(c, { mp: 1, carrier: 0, som: 0, cp: 0, del: 0, n: 10 }); + assert.deepEqual([c.sequenced, c.carriers, c.carrierSomatic, c.carrierCnaProfiled, c.carrierDeletion, c.carrierEither], [19, 5, 2, 2, 2, 2]); + assert.deepEqual([c.noncarriers, c.noncarrierEither, c.noncarrierDeletion, c.noncarrierCnaProfiled], [14, 4, 4, 4]); +}); + +test("a carrier whose panel omits the gene is counted apart, never as sequenced", () => { + const c = blank(); + germAdd(c, { mp: 0, carrier: 1, som: 0, cp: 0, del: 0, n: 2 }); + germAdd(c, { mp: 0, carrier: 0, som: 0, cp: 0, del: 0, n: 7 }); + assert.equal(c.outsidePanel, 2); + assert.equal(c.sequenced, 0); +}); diff --git a/test/hla.test.ts b/test/hla.test.ts new file mode 100644 index 0000000..bc0b957 --- /dev/null +++ b/test/hla.test.ts @@ -0,0 +1,50 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { hlaPanel, covariateColumns, type HlaRec } from "../lib/views/hla.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; + +const rec = (hom: (number | null)[], loss: number | null, t: number | null, ev: number, cov: (string | null)[] = []): HlaRec => ({ hom, nf: hom.some((h) => h != null) ? 2 : null, loss, t, ev, cov, study: "s" }); + +describe("hla counts", () => { + const recs: HlaRec[] = []; + for (let i = 0; i < 12; i++) recs.push(rec([0, 0, 0], i % 3 === 0 ? 1 : 0, 10 + i, i % 2, [String(i)])); + for (let i = 0; i < 6; i++) recs.push(rec([1, 0, 0], null, 5 + i, 1, [String(i)])); + recs.push(rec([1, 1, 0], 0, 3, 1, ["2"]), rec([1, 1, 1], 0, 2, 1, [null])); + recs.push(rec([0, null, 0], 0, 8, 0, ["1"]), rec([null, null, null], null, null, 0)); + const pn = hlaPanel("c", 25, recs, 2, 1, false); + test("homozygosity over patients typed at all three loci; LOH over patients with a call", () => { + assert.equal(pn.typed, 20); + assert.deepEqual(pn.homozygous, [12, 6, 1, 1]); + assert.equal(pn.anyTyped, 21); + assert.deepEqual(pn.loci.map((l) => [l.typed, l.homozygous]), [[21, 8], [20, 2], [21, 1]]); + assert.equal(pn.lohEvaluated, 15); + assert.equal(pn.loh, 4); + assert.equal(pn.noOutcome, 1); + }); + test("homozygous patients die sooner here, so the HR is above 1; a missing covariate drops one patient from the adjusted fit", () => { + const s = pn.byHomozygosity!; + assert.equal(s.curves[0].n, 8); + assert.equal(s.curves[1].n, 12); + assert.ok(s.hr && s.hr.hr > 1 && s.logrank && s.logrank.p < 0.05); + assert.equal(s.adjusted!.n, 19); + }); + test("a split with too few patients in a group says why", () => { + const small = hlaPanel("c", 3, recs.slice(0, 14), 2, 0, false); + assert.equal(small.byHomozygosity, null); + assert.match(small.byHomozygosityWhy!, /fewer than 5/); + }); + test("covariates: numbers as they are, categories as indicators against the commonest", () => { + const rs = ["a", "a", "b", "c"].map((v) => rec([0, 0, 0], null, 1, 1, [v, "3"])); + const cols = covariateColumns(rs, 2); + assert.deepEqual(rs.map((r) => cols.flatMap((c) => c(r))), [[0, 0, 3], [0, 0, 3], [1, 0, 3], [0, 1, 3]]); + }); + test("the figure lints clean at both widths", () => { + const v = { kind: "hla" as const, cohorts: ["c"], caveat: "x", hla: { resolution: 2 as const, endpoint: "OS", covariates: ["TMB"], panels: [pn] } }; + for (const width of [560, 936]) { + const svg = viewToSvg(v, { width })!; + assert.ok(svg.includes("OS by homozygosity")); + assert.deepEqual(lintSvg(svg), []); + } + }); +}); diff --git a/test/instability.test.ts b/test/instability.test.ts new file mode 100644 index 0000000..bc7cdb2 --- /dev/null +++ b/test/instability.test.ts @@ -0,0 +1,33 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { HG19_CENTROMERES, CHROM_LENGTHS, GENOME_CHROMS, instabilityArms } from "../lib/federation-views.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { DrawnResultOf } from "../lib/types.ts"; + +describe("instability arms", () => { + test("39 autosomal arms (no acrocentric p arms), 41 with X; every centromere inside its chromosome", () => { + assert.equal(instabilityArms(false).length, 39); + assert.equal(instabilityArms(true).length, 41); + for (const c of ["13", "14", "15", "21", "22"]) assert.ok(!instabilityArms(false).includes(c + "p")); + for (const c of GENOME_CHROMS) assert.ok(HG19_CENTROMERES[c] > 0 && HG19_CENTROMERES[c] < CHROM_LENGTHS.hg19[c], c); + }); +}); + +describe("instability figure", () => { + const arms = instabilityArms(false); + const hist = (n: number, at: number) => Array.from({ length: n }, (_, i) => Math.abs(i - at) < 3 ? 10 : 1); + const panel = (cohort: string, at: number) => ({ cohort, samples: 100, segmented: 98, allArms: 95, score: hist(40, at), fga: hist(101, at * 2), + scoreSpread: { p5: 1, q1: at - 2, median: at, q3: at + 2, p95: at + 10, mean: at }, fgaSpread: { p5: 1, q1: 2 * at - 4, median: 2 * at, q3: 2 * at + 4, p95: 2 * at + 20, mean: 2 * at }, + arms: arms.map((a, k) => ({ arm: a, measured: 98, gain: k % 7, loss: k % 5 })) }); + const v: DrawnResultOf<"instability"> = { kind: "instability", cohorts: ["Colorectal", "Breast"], caveat: "purity is not corrected", + instability: { build: "hg19", threshold: 0.2, armShare: 0.5, includeX: false, arms, panels: [panel("Colorectal", 7), panel("Breast", 9)], test: { name: "Mann–Whitney U", scoreP: 1e-5, fgaP: 0.2 } } }; + test("draws both boxes and the arm heatmap, and lints clean", () => { + for (const width of [560, 936, 1352]) { + const svg = viewToSvg(v, { width })!; + assert.ok(svg.includes("Colorectal — 20q gained in")); + assert.ok(svg.includes("Mann–Whitney U: aneuploidy score")); + assert.deepEqual(lintSvg(svg), []); + } + }); +}); diff --git a/test/molsubtype.test.ts b/test/molsubtype.test.ts new file mode 100644 index 0000000..c1c9a08 --- /dev/null +++ b/test/molsubtype.test.ts @@ -0,0 +1,21 @@ +// The molecular-subtype view's group pooling, on fixed inputs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { molTopGroups, POLE_HOTSPOTS } from "../lib/views/molsubtype.ts"; + +const g = (counts: number[], nc = 0): { counts: number[]; classified: number; notClassifiable: number; why: Record } => ({ counts, classified: counts.reduce((a, b) => a + b, 0), notClassifiable: nc, why: nc ? { "no MSI call": nc } : {} }); + +test("the largest groups are kept and the rest pooled into a named Other, counts and reasons summed", () => { + const groups = new Map([["EUR", g([5, 10, 20, 30], 3)], ["AFR", g([1, 2, 15, 3], 1)], ["EAS", g([1, 1, 2, 2])], ["SAS", g([0, 1, 1, 1], 2)]]); + const r = molTopGroups(groups, 2, 4); + assert.deepEqual(r.groups.map((x) => x.label), ["EUR", "AFR", "Other (2)"]); + assert.deepEqual(r.groups[2].counts, [1, 2, 3, 3]); + assert.equal(r.groups[2].notClassifiable, 2); + assert.deepEqual(r.groups[2].why, { "no MSI call": 2 }); + assert.deepEqual(r.pooled, ["EAS", "SAS"]); +}); + +test("the POLE hotspots are protein changes the SQL accepts", () => { + for (const h of POLE_HOTSPOTS) assert.match(h, /^[A-Z][0-9]{1,5}[A-Z*]$/); + assert.ok(POLE_HOTSPOTS.includes("P286R") && POLE_HOTSPOTS.includes("V411L")); +}); diff --git a/test/multistate.test.ts b/test/multistate.test.ts new file mode 100644 index 0000000..641bb7f --- /dev/null +++ b/test/multistate.test.ts @@ -0,0 +1,75 @@ +// The multistate view's statistics and figure: the illness–death estimators on a toy cohort, the +// Cox partial likelihood with delayed entry, and the drawing. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { illnessDeath, coxPartialLogLik, coxRegression, type CoxRow } from "../lib/stats.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { ViewResult } from "../lib/types.ts"; + +const close = (a: number, b: number) => assert.ok(Math.abs(a - b) < 1e-12, a + " vs " + b); + +describe("illnessDeath", () => { + // A progresses at 1 and dies at 3; B dies at 2 without progressing; C is censored at 4; D progresses at 2, censored at 5 + const recs = [{ prevalent: false, tp: 1, te: 3, dead: true }, { prevalent: false, tp: null, te: 2, dead: true }, + { prevalent: false, tp: null, te: 4, dead: false }, { prevalent: false, tp: 2, te: 5, dead: false }]; + test("Nelson–Aalen by hand: 01 rises 1/4 then 1/3, 02 by 1/3, 12 by 1/2", () => { + const f = illnessDeath(recs); + assert.deepEqual(f.cumhaz["01"].map((q) => q.t), [0, 1, 2]); + close(f.cumhaz["01"][2].h, 1 / 4 + 1 / 3); + close(f.cumhaz["02"][1].h, 1 / 3); + close(f.cumhaz["12"][1].h, 1 / 2); + assert.deepEqual(f.events, { "01": 2, "02": 1, "12": 1, c0: 1, c1: 1 }); + assert.deepEqual(f.personTime, { s0: 1 + 2 + 4 + 2, s1: 2 + 3 }); + }); + test("Aalen–Johansen by hand, and equal to the empirical shares without censoring before t", () => { + const o = illnessDeath(recs).occupation; + assert.deepEqual(o.map((q) => q.t), [0, 1, 2, 3]); + close(o[1].p0, 0.75); close(o[1].p1, 0.25); + close(o[2].p0, 0.25); close(o[2].p1, 0.5); close(o[2].p2, 0.25); + close(o[3].p0, 0.25); close(o[3].p1, 0.25); close(o[3].p2, 0.5); + }); + test("a prevalent patient starts in the middle state and is at risk of death from 0", () => { + const f = illnessDeath([{ prevalent: true, tp: null, te: 1, dead: true }, { prevalent: false, tp: null, te: 2, dead: false }]); + close(f.occupation[0].p1, 0.5); + close(f.cumhaz["12"][1].h, 1); + close(f.occupation[1].p2, 0.5); + }); +}); + +describe("Cox with delayed entry", () => { + const rows: CoxRow[] = [ + { t: 2, event: 1, x: [1] }, { t: 3, event: 1, x: [0], entry: 1 }, { t: 5, event: 0, x: [1], entry: 2.5 }, + { t: 6, event: 1, x: [0], entry: 4 }, { t: 7, event: 1, x: [1] }, { t: 8, event: 0, x: [0], entry: 6.5 }, { t: 4, event: 1, x: [0] }, + ]; + // the partial likelihood over risk sets {entry < t_i ≤ t_j}, written out + const brute = (b: number) => rows.filter((r) => r.event).reduce((ll, r) => ll + b * r.x[0] - + Math.log(rows.filter((j) => (j.entry ?? -Infinity) < r.t && j.t >= r.t).reduce((s, j) => s + Math.exp(b * j.x[0]), 0)), 0); + for (const b of [-0.7, 0, 0.4]) test("partial log-likelihood at β = " + b + " matches the counting-process risk sets", () => close(coxPartialLogLik(rows, [b]), brute(b))); + test("an entry before every event time changes nothing", () => { + const plain = rows.map(({ t, event, x }) => ({ t, event, x })); + const early = plain.map((r) => ({ ...r, entry: 0 })); + close(coxRegression(early).beta[0], coxRegression(plain).beta[0]); + }); +}); + +describe("multistate figure", () => { + const occ = [{ t: 0, p0: 0.8, p1: 0.2, p2: 0 }, { t: 3, p0: 0.6, p1: 0.3, p2: 0.1 }, { t: 12, p0: 0.3, p1: 0.4, p2: 0.3 }]; + const ch = [{ t: 0, h: 0, lo: 0, hi: 0 }, { t: 2, h: 0.1, lo: 0.05, hi: 0.2 }, { t: 10, h: 0.4, lo: 0.3, hi: 0.5 }]; + const cohort = (name: string) => ({ cohort: name, cohortPatients: 100, n: 90, prevalent: 18, excluded: { noFollowup: 10, prevalent: 0 }, + notAssessed: 3, afterEnd: 0, seq0: 90, late: 0, censored: { start: 20, progression: 10 }, + rates: (["01", "02", "12"] as const).map((key) => ({ key, events: 12, personMonths: 400, rate: 3, lo: 1.7, hi: 5.3 })), + occupation: occ, cumhaz: { "01": ch, "02": ch, "12": ch }, drawnTo: 14, + atHorizon: [{ months: 12, p0: 0.3, p1: 0.4, p2: 0.3, atRisk: 20 }, { months: 60, p0: null, p1: null, p2: null, atRisk: 2 }] }); + const v = { kind: "multistate", cohorts: ["A ", "B"], caveat: "not randomised", + multistate: { progression: { eventType: "Diagnosis", key: "PROGRESSION", values: ["Y"] }, prevalent: "include", horizons: [12, 60], tMax: 14, + origin: "first sequencing", cohorts: [cohort("A "), cohort("B")], reference: "A ", + cox: [{ key: "01", rows: [{ cohort: "B", hr: 1.4, lo: 1.1, hi: 1.9, p: 0.004 }], n: 160, events: 24 }, + { key: "02", rows: [], n: 160, events: 24, noFit: "B has fewer than 5 events" }, { key: "12", rows: [{ cohort: "B", hr: 0.8, lo: 0.5, hi: 1.2, p: 0.3 }], n: 50, events: 24 }] } } as ViewResult; + for (const width of [560, 936]) test("draws, escapes names and lints clean at " + width, () => { + const svg = viewToSvg(v, { width })!; + assert.match(svg, /A <x>/); + assert.match(svg, /progression → death/); + assert.deepEqual(lintSvg(svg), []); + }); +}); diff --git a/test/organotropism.test.ts b/test/organotropism.test.ts new file mode 100644 index 0000000..77ebbb8 --- /dev/null +++ b/test/organotropism.test.ts @@ -0,0 +1,27 @@ +// The organotropism view's per-cell test, on fixed inputs. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { orgCell, flagLabel } from "../lib/views/organotropism.ts"; +import { cochranMantelHaenszel } from "../lib/stats.ts"; + +test("a cell's strata become 2×2 tables, pooled for display and tested by CMH", () => { + const strata = [{ n: 100, withSite: 30, altered: 20, both: 10 }, { n: 50, withSite: 10, altered: 25, both: 8 }]; + const c = orgCell("LIVER", "TP53", strata, 5); + assert.deepEqual([c.withSite, c.withAltered, c.without, c.withoutAltered], [40, 18, 110, 27]); + const t = cochranMantelHaenszel([{ a: 10, b: 10, c: 20, d: 60 }, { a: 8, b: 17, c: 2, d: 23 }]); + assert.equal(c.p, t.p); + assert.equal(c.or, t.oddsRatio); + assert.equal(c.strata, 2); + assert.ok(c.log2Or! > 0); +}); + +test("a cell short of altered, with-site or without-site patients is not tested", () => { + assert.equal(orgCell("BONE", "EGFR", [{ n: 100, withSite: 30, altered: 4, both: 2 }], 5).p, null); + assert.equal(orgCell("BONE", "EGFR", [{ n: 100, withSite: 97, altered: 40, both: 39 }], 5).p, null); + assert.equal(orgCell("BONE", "EGFR", [], 5).withSite, 0); +}); + +test("flag suffixes read as labels", () => { + assert.equal(flagLabel("CNS_BRAIN"), "CNS Brain"); + assert.equal(flagLabel("ADRENAL_GLAND"), "Adrenal Gland"); +}); diff --git a/test/predictive.test.ts b/test/predictive.test.ts new file mode 100644 index 0000000..99f95a9 --- /dev/null +++ b/test/predictive.test.ts @@ -0,0 +1,37 @@ +// The predictive view's per-gene fit: the two contrasts and the interaction are one model's. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { fitPredictiveGene } from "../lib/views/predictive.ts"; +import { seededRandom } from "../lib/stats.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { ViewResult } from "../lib/types.ts"; + +test("HR among the exposed = HR among the unexposed × the interaction HR, and a planted interaction is found", () => { + const rand = seededRandom(7); + const rows = Array.from({ length: 600 }, (_, i) => { + const exposed = i % 2 === 0, mutated = i % 3 === 0; + // mutation harmful only when exposed: hazard ×3 + const rate = 0.1 * (mutated && exposed ? 3 : 1); + const t = -Math.log(1 - rand()) / rate; + return { t: Math.min(t, 20), ev: t < 20 ? 1 : 0, exposed, mutated, x: [] }; + }); + const f = fitPredictiveGene(rows); + assert.ok(Math.abs(Math.log(f.inExposed.hr) - Math.log(f.inUnexposed.hr) - Math.log(f.interaction.hr)) < 1e-6); + assert.ok(f.inExposed.hr > 2 && f.inExposed.hr < 4.5, "exposed HR " + f.inExposed.hr); + assert.ok(f.inUnexposed.hr > 0.6 && f.inUnexposed.hr < 1.6, "unexposed HR " + f.inUnexposed.hr); + assert.ok(f.interaction.p < 1e-4); +}); + +test("the figure draws a skipped gene as a note and lints clean", () => { + const e = { hr: 1.5, ciLow: 1.1, ciHigh: 2, p: 0.01 }; + const v = { kind: "predictive", cohorts: ["NSCLC"], caveat: "immortal time", + predictive: { exposure: "Treatment [SUBTYPE=Immuno]", endpoint: "OS", measure: "any", n: 100, exposed: 40, events: 50, notProfiled: 0, noCovariate: 0, covariates: [], minMutated: 5, strata: 1, + genes: [{ gene: "STK11", exposed: 40, unexposed: 60, mutatedExposed: 8, mutatedUnexposed: 9, events: 50, inExposed: e, inUnexposed: { ...e, hr: 0.9, ciLow: 0.5, ciHigh: 1.6 }, interaction: e, q: 0.02 }, + { gene: "EGFR", exposed: 40, unexposed: 60, mutatedExposed: 2, mutatedUnexposed: 9, events: 50, inExposed: null, inUnexposed: null, interaction: null, q: null, skipped: "too few mutated" }] } } as ViewResult; + for (const width of [560, 936]) { + const svg = viewToSvg(v, { width })!; + assert.match(svg, /not fitted: too few mutated/); + assert.deepEqual(lintSvg(svg), []); + } +}); diff --git a/test/riskmodel-validate.test.ts b/test/riskmodel-validate.test.ts new file mode 100644 index 0000000..cbd2f2b --- /dev/null +++ b/test/riskmodel-validate.test.ts @@ -0,0 +1,27 @@ +// External validation of a risk model: scored with the ensemble's mean coefficients, cut at the +// training thresholds, and a patient not covered for a model gene left out, never wild-type. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { riskExternal } from "../lib/views/riskmodel-validate.ts"; +import type { RiskFit, RiskGrouping, RiskFitArgs, RiskResolver } from "../lib/views/riskmodel.ts"; + +test("scores by selected × mean coefficient, cuts at the training threshold, leaves the uncovered out", async () => { + const rows = [ + ...Array.from({ length: 40 }, (_, i) => ({ m: 5 + (i % 7), ev: 1, half: 0, panels: ["P"], genes: ["A"] })), + ...Array.from({ length: 40 }, (_, i) => ({ m: 20 + (i % 9), ev: i % 2, half: 0, panels: ["P"], genes: [] })), + { m: 3, ev: 1, half: 0, panels: ["Q"], genes: [] }, + ]; + const r = { queries: [] as { label: string; sql: string }[], + viewRows: async () => rows, viewMeta: async () => [{ panel: "P", genes: ["A", "B"] }, { panel: "Q", genes: ["B"] }] } as unknown as RiskResolver; + const fit = { anchored: false, features: [{ gene: "A", selected: 0.5, meanCoef: 1, hr: Math.E, mutated: 10 }, { gene: "B", selected: 0, meanCoef: 0, hr: 1, mutated: 0 }] } as unknown as RiskFit; + const g = { groups: [{ name: "Low risk", from: null }, { name: "High risk", from: 0.2 }] } as unknown as RiskGrouping; + const args = { study: "s", def: { all: [] }, compile: { oncokbPin: null, profiles: [], gaProfiles: [] }, view: { kind: "riskmodel" } } as unknown as RiskFitArgs; + const { external: e } = await riskExternal(r, args, "V", fit, g, 1); + assert.equal(e.notCovered, 1); + assert.equal(e.modelGenes, 1); + assert.equal(e.n, 80); + assert.deepEqual(e.groups.map((x) => x.curve.n), [40, 40]); + assert.ok(e.cIndex > 0.8, "the mutated die first: " + e.cIndex); + assert.ok(e.cIndexLo <= e.cIndex && e.cIndex <= e.cIndexHi); + assert.ok(e.groups[1].hr! > 1); +}); diff --git a/test/rmst.test.ts b/test/rmst.test.ts new file mode 100644 index 0000000..4c07700 --- /dev/null +++ b/test/rmst.test.ts @@ -0,0 +1,64 @@ +// The RMST view's statistics and figure: the restricted mean and its variance against a hand +// computation, the contrast, the landmark reset, and the drawing. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { rmst } from "../lib/stats.ts"; +import { fromLandmark, rmstContrast } from "../lib/views/rmst.ts"; +import { kaplanMeier } from "../lib/stats.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { ViewResult } from "../lib/types.ts"; + +const close = (a: number, b: number, eps = 1e-12) => assert.ok(Math.abs(a - b) < eps, a + " vs " + b); + +describe("rmst", () => { + // events at 1, 3, 4; censored at 2 and 5. KM: 0.8 after 1, 0.8·2/3 after 3, ·1/2 after 4 + const toy = [{ t: 1, event: 1 }, { t: 2, event: 0 }, { t: 3, event: 1 }, { t: 4, event: 1 }, { t: 5, event: 0 }]; + test("area to τ = 4.5 by hand: 1 + 2·0.8 + 1·(8/15) + 0.5·(4/15)", () => { + close(rmst(toy, 4.5).rmst, 1 + 1.6 + 8 / 15 + 0.5 * 4 / 15); + }); + test("variance by hand: Σ A_j² d_j / (n_j (n_j − d_j)) over the three events", () => { + const A4 = 0.5 * 4 / 15, A3 = 8 / 15 + A4, A1 = 1.6 + A3; + close(rmst(toy, 4.5).se, Math.sqrt(A1 * A1 / 20 + A3 * A3 / 6 + A4 * A4 / 2)); + }); + test("an event at τ adds nothing; before the first event the area is τ itself", () => { + close(rmst(toy, 4).rmst, 1 + 1.6 + 8 / 15); + assert.deepEqual([rmst(toy, 0.5).rmst, rmst(toy, 0.5).se], [0.5, 0]); + }); + test("no censoring: the restricted mean is the mean of min(t, τ)", () => { + const t = [0.5, 1.5, 2, 3.5, 6, 7]; + close(rmst(t.map((x) => ({ t: x, event: 1 })), 4).rmst, t.reduce((a, x) => a + Math.min(x, 4), 0) / t.length); + }); + test("delayed entry: a patient is not at risk before entering", () => { + const r = rmst([{ t: 1, event: 1 }, { t: 3, event: 0, entry: 2 }, { t: 4, event: 0 }], 4); + close(r.rmst, 1 + 3 * 0.5); + }); +}); + +describe("rmst contrast and landmark", () => { + test("difference and ratio with Wald intervals", () => { + const c = rmstContrast({ rmst: 12, se: 0.6 }, { rmst: 10, se: 0.8 }); + close(c.diff, 2); close(c.diffHi - c.diff, 1.959963984540054); + close(c.ratio, 1.2); close(Math.log(c.ratioHi / c.ratio), 1.959963984540054 * Math.sqrt(0.05 ** 2 + 0.08 ** 2)); + }); + test("a landmark keeps those followed past it and restarts the clock", () => { + const recs = [{ t: 2, event: 1 }, { t: 6, event: 0 }, { t: 7, event: 1 }, { t: 9, event: 0, entry: 8 }]; + assert.deepEqual(fromLandmark(recs, 6), [{ t: 1, event: 1 }, { t: 3, event: 0, entry: 2 }]); + assert.equal(fromLandmark(recs, 0), recs); + }); +}); + +describe("rmst figure", () => { + const curve = (cohort: string, k: number) => ({ cohort, ...kaplanMeier([1, 2, 3 + k, 5, 8, 9, 12, 14].map((t, i) => ({ t, event: i % 3 ? 1 : 0 }))), + rmst: 7 + k, se: 0.5, lo: 6 + k, hi: 8 + k, atRiskTau: 3, beforeLandmark: 0 }); + const cmp = { cohort: "B", vs: "A ", diff: 1, diffLo: -0.4, diffHi: 2.4, diffP: 0.16, ratio: 1.14, ratioLo: 0.95, ratioHi: 1.37, ratioP: 0.16 }; + const panel = (landmark: number) => ({ landmark, tau: 10, tauSet: true, curves: [curve("A ", 0), curve("B", 1)], comparisons: [cmp] }); + const v = { kind: "rmst", cohorts: ["A ", "B"], caveat: "not randomised", + rmst: { endpointLabel: "OS", panels: [panel(0), panel(6)], cohorts: [] } } as unknown as ViewResult; + for (const width of [560, 936]) test("draws, escapes names and lints clean at " + width, () => { + const svg = viewToSvg(v, { width })!; + assert.match(svg, /A <x>/); + assert.match(svg, /τ = 10/); + assert.deepEqual(lintSvg(svg), []); + }); +}); diff --git a/test/txendpoints.test.ts b/test/txendpoints.test.ts new file mode 100644 index 0000000..f49977c --- /dev/null +++ b/test/txendpoints.test.ts @@ -0,0 +1,37 @@ +// The treatment-endpoints view's statistic and figure: the KM median's interval, and the drawing. +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { kmMedianCi } from "../lib/stats.ts"; +import { viewToSvg } from "../lib/view-svg.ts"; +import { lintSvg } from "../lib/chart/lint.ts"; +import type { ViewResult } from "../lib/types.ts"; + +describe("kmMedianCi", () => { + test("ten events at 1..10 months: median 5, lower end 3, no upper end (R survfit, conf.type log, gives 3 and NA)", () => { + const r = kmMedianCi(Array.from({ length: 10 }, (_, i) => ({ t: i + 1, event: 1 }))); + assert.deepEqual(r, { median: 5, lo: 3, hi: null }); + }); + test("a curve that never reaches one half has no median and no interval", () => { + const r = kmMedianCi([{ t: 1, event: 1 }, ...Array.from({ length: 9 }, (_, i) => ({ t: i + 2, event: 0 }))]); + assert.deepEqual(r, { median: null, lo: null, hi: null }); + }); + test("delayed entry: a patient not yet entered is not at risk", () => { + const recs = [{ t: 1, event: 1 }, { t: 2, event: 1 }, { t: 5, event: 1, entry: 3 }, { t: 6, event: 0, entry: 3 }]; + assert.equal(kmMedianCi(recs).median, 1); + assert.equal(kmMedianCi(recs.map(({ t, event }) => ({ t, event }))).median, 2); + }); +}); + +describe("txendpoints figure", () => { + const curve = (cohort: string, k: number) => ({ cohort, points: [{ t: 0, s: 1 }, { t: 2, s: 0.6 }, { t: 5 + k, s: 0.3 }], median: 5 + k, medianLo: 2, medianHi: null, + n: 40, events: 20, atRisk: [40, 35, 30, 20, 15, 10, 8, 5], cohortPatients: 100, anchored: 45, excluded: { noAnchor: 55, noFollowup: 5, noStop: 0 }, late: 0 }); + const v = { kind: "txendpoints", cohorts: ["A ", "B"], caveat: "not randomised", + txendpoints: { endpoint: "ttnt", endpointLabel: "time to next treatment", anchoredTo: "first Treatment [SUBTYPE=Immuno]", lineGap: 90, lineClasses: "systemic", + curves: [curve("A ", 0), curve("B", 1)], logrank: { chi2: 1.2, p: 0.27, df: 1, observed: [20, 20], expected: [18, 22], groups: 2, strata: 1 } } } as ViewResult; + for (const width of [560, 936]) test("draws, escapes names and lints clean at " + width, () => { + const svg = viewToSvg(v, { width })!; + assert.match(svg, /A <x>/); + assert.match(svg, /log-rank/); + assert.deepEqual(lintSvg(svg), []); + }); +});