Skip to content

Nineteen literature-derived analysis views (preview), one commit each - #59

Merged
alisman merged 27 commits into
masterfrom
session/new-features
Oct 23, 2026
Merged

alisman merged 27 commits into
masterfrom
session/new-features

Conversation

@alisman

@alisman alisman commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Twelve analysis views taken from the cancer genomics literature, as previews: each is drawn, runs on the public warehouse, and says in its caveat what it computed and what it leaves out. None has a method record or golden test yet. One commit per view, on top of a setup commit.

Why they're previews. lib/preview-kinds.ts lists each new kind with what it still lacks. Each listed view gets a Preview badge in its header, whose title names what is pending. The same list exempts the kind in check:methods. Removing a kind's entry puts it back under the full checks.

The candidates came from searching the abstracts of the 400 PubMed papers linked to cBioPortal's 551 public studies for analysis methods, keeping the ones no existing view covers (survey).

View What it shows Gallery example (public)
txendpoints Real-world treatment endpoints from a treatment start: time to next treatment (lines from lib/treatment-lines.ts), time on treatment, real-world PFS (NLP progression events). KM + log-rank, median CIs. msk_chord_2024 NSCLC on immunotherapy, KRAS mutant vs wild-type
predictive Gene × treatment interaction per gene. Cox on gene + exposed + gene×exposed, panel-aware, BH on the interaction. HR among treated vs untreated as a two-marker forest. msk_chord_2024 NSCLC, Immuno: STK11/KEAP1 adverse; TP53, SMARCA4 interaction q < 1e-4
cutpoint A biomarker's best survival cut: maximally selected log-rank, with a seeded permutation-corrected p beside the naive one, and optionally a cut per stratum. tmb_mskcc_2018 TMB: cut 14.4, HR 0.45, corrected p 0.001
riskmodel validateOn External validation of the OncoCast-style ensemble on another cohort: Harrell's C with bootstrap CI, KM by training thresholds, per-group HRs. Without the option the result is byte-identical (JSON and SVG diffed). train msk_impact_2017 NSCLC, validate msk_chord_2024: C 0.607
forest One exposure's HR per cancer type, DerSimonian–Laird random-effects pooled (plus fixed effect), τ², I², Q. msk_impact_50k_2026 TP53, 29 types: pooled HR 1.63, I² 93%
adjusted Covariate-adjusted alteration enrichment between two cohorts: per-gene logistic regression, adjusted OR beside raw OR and Fisher, BH. msk_impact_50k_2026 early- vs average-onset CRC, adjusted for MSI and TMB
actionability Each patient's highest OncoKB level by cancer type, and primary-vs-metastasis concordance. Levels are the store's best level in any cancer type, an upper bound; the caveat and axis say so. msk_impact_50k_2026 by cancer type
drivernegative Patients without a canonical driver (default list editable) against those with one, every other gene tested (Fisher, BH), panel-aware. msk_chord_2024 NSCLC: 37% driver-negative; NF1, NFE2L2, KEAP1 enriched
acquired Alterations acquired under therapy: last sample before a treatment start vs first after, per gene acquired/lost/shared, McNemar exact, BH. lung_msk_pdx, systemic therapy (33 pairs)
ctdna Plasma cfDNA against tissue within patients: per-gene agreement (Wilson CI), ctDNA-detected share, OS by ctDNA detected. nsclc_ctdx_msk_2022: 682 pairs, EGFR PPA 64%
exclusivity Mutual exclusivity against each tumour's alteration burden (DISCOVER): Poisson-binomial tests from a burden-aware background, plain Fisher beside it, optional strata. msk_impact_2017 NSCLC: KRAS/EGFR exclusive; the Fisher EGFR/TP53 co-occurrence goes away
instability Aneuploidy score, FGA and arm-level gains/losses from segments (hg19 arm boundaries), Kruskal–Wallis across cohorts, an arm × cohort heatmap. Aggregate-only. msk_impact_50k_2026 colorectal / breast / pancreatic

Round two adds seven more, the same way:

View What it shows Gallery example (public)
multistate Illness–death model: start → progression (NLP) → death. Transition rates, Aalen–Johansen state occupancy, a Cox HR per transition between cohorts (Markov clock, delayed entry). msk_chord_2024 colorectal stage 4 vs 1–3: start→progression HR 4.4
rmst Restricted mean survival time to τ with CIs, difference and ratio between cohorts, landmark small multiples, optional treatment anchor. tmb_mskcc_2018 TMB ≥ 10 vs < 10: +3.3 months to 24 months
organotropism Gene × metastatic-site enrichment (CMH stratified by cancer type, BH), from per-organ flags, timeline tumour sites or mapped METASTATIC_SITE; cumulative incidence of one site by a gene, death competing. msk_met_2021: TP53/liver, EGFR/brain; msk_chord_2024 EGFR brain incidence 32% vs 23% at 5 years
molsubtype Endometrial molecular subtypes (POLE → MMRd → p53abn → NSMP, WHO 2020), by an attribute, OS by subtype; "not classifiable" disclosed. msk_impact_50k_2026 by ancestry; matches TCGA UCEC subtypes (POLE 48/49)
germline Germline carriers per gene (panel-aware) and their somatic second hits vs non-carriers. LOH not assessed. prad_su2c_2019: BRCA1/2
hla HLA class I homozygosity and HLA LOH, OS by each, adjusted Cox. Refuses studies without HLA alleles. msk_impact_50k_2026 NSCLC: HLA LOH HR 1.35
ctdna chFilter Clonal-haematopoiesis-aware plasma calls: a CH gene list and a matched-normal rule, with before/after. Unset is byte-identical. msk_access_2021: 83 of 2,024 plasma calls removed

Shared code these added:

  • lib/figures/km.ts, a KM panel with a number-at-risk table. Added to the kind-boundary shared list.
  • dualForestSvg and metaForestSvg in lib/figures/forest.ts.
  • An optional pName on the volcano. Unset, every other volcano draws as before.
  • In lib/stats.ts: kmMedianCi, wilsonInterval and poissonBinomialTails.
  • The paired view's per-gene counting, moved into a shared sideCountsTail. The paired view's SQL is byte-identical in all four configurations.

Checked:

  • tsc, eslint, and npm test (1,554 pass).
  • docs:check: every gallery example runs and its counts are pinned.
  • scripts/gate.sh: every lane green. All 172 e2e checks pass, after one test fix: two oncoprint checks (the merged-row tooltip, drag-to-cohort) now scroll the canvas into view first, since the larger add-view panel pushed it below the window.
  • Each gallery figure linted at 936 and 560 px.

Not done:

  • Method records, golden tests and /verification rows for the new statistics; these are what removing a kind from PREVIEW_KINDS will need.
  • The new cards' controls haven't been checked by e2e.
  • Not yet run against the internal mskimpact data. Demo documents for it come separately.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC

@vercel

vercel Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cbiolab-genie Ready Ready Preview Oct 23, 2026 1:59pm UTC
cohort-lab Ready Ready Preview Oct 23, 2026 1:59pm UTC

Request Review

@alisman
alisman force-pushed the session/new-features branch from cb0d49e to 0c02064 Compare October 23, 2026 03:29
@alisman
alisman force-pushed the session/new-features branch from f80f72d to 9331b24 Compare October 23, 2026 03:37
@alisman
alisman force-pushed the session/new-features branch from 9331b24 to 30d160f Compare October 23, 2026 03:38
@alisman
alisman force-pushed the session/new-features branch from 8b97f80 to 443bcd2 Compare October 23, 2026 03:42
@alisman
alisman force-pushed the session/new-features branch from b33f9c8 to fb2e7e3 Compare October 23, 2026 05:07
@alisman alisman changed the title Twelve literature-derived analysis views (preview), one commit each Nineteen literature-derived analysis views (preview), one commit each Oct 23, 2026
@alisman
alisman force-pushed the session/new-features branch from fb2e7e3 to b5614ce Compare October 23, 2026 07:42
alisman and others added 8 commits October 23, 2026 12:41
…k's exemption

lib/preview-kinds.ts lists view kinds that are drawn and usable but not yet held to
invariant 12 (no method record, no golden test), each with what it still lacks. ViewHead
shows such a kind with a Preview badge whose title names what is pending, and
scripts/check-method-scripts.ts exempts exactly these kinds, so removing a kind's entry
puts it back under both checks at once. Empty here; the analysis views that follow add
themselves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…s after a treatment start

A new view kind. For each cohort patient, the anchor is their FIRST timeline event of
`anchor.eventType` (default Treatment, any; key/value such as SUBTYPE=Hormone or AGENT=…,
folded through foldedEqualsSql). A sample's date is its first timeline event of `sampleEvents`
(default Sample acquisition / Sequencing / Specimen) naming it in `sampleKey` (default
SAMPLE_ID), matched to the study's samples by stable id. The before-sample is the last on or
before the anchor day (optionally within `preWindowDays`), the after-sample the first at least
`minGapDays` (default 30) after; one sample per side, ties broken by sample id.

Per gene, among patients whose before- AND after-sample panels both cover it: acquired (after
only), lost (before only), shared, neither; exact McNemar on acquired vs lost (mcnemarExact), BH
across the genes tested per cohort. `measure: "driver"` counts only OncoKB oncogenic calls (the
pinned store); default any alteration. A gene list (at most 30) or, without one, a scan of every
gene altered after treatment in at least `minPost` (default 3) patients, the `top` (default 20,
max 40) smallest p drawn. The result carries the funnel (anchored, with a sample before, after,
both) and the pairs' spacing (median days before/after, gap quartiles). Refuses past 3 cohorts /
30 genes, bad windows, a driver count without a pin, and a cohort with no pair — naming the
funnel so the fix is visible.

The per-gene counting is the paired view's, moved into a shared `sideCountsTail` in
lib/federation-views.ts that both views put a `ps` (suid, pid, side) CTE in front of. The
paired view's SQL is byte-identical: pairedCountsSql's output was compared, old file against
new, for named genes and scan, driver and any — all four equal.

Figure: diverging bars per gene, lost left, acquired right, one symmetric scale, with shared,
pairs and McNemar q columns; caption is the caveat (selected patients — rebiopsy is clinically
indicated — acquired ≠ caused; a missed subclone or lower-purity sample reads the same).

Verified on lung_msk_pdx (gallery example, SUBTYPE = Systemic therapy, drivers, scan, top 12):
172 patients, 142 with a start, 67 with a sample before, 107 after, 33 with both, samples a
median 301 days apart (IQR 154–514); 30 genes tested, CDKN2B 8 acquired / 1 lost (p 0.039,
q 0.50), MYC 4/0, KEAP1 5/1. msk_chord_2024 SUBTYPE=Chemo gives 28 pairs, any Treatment 51.
Figure lint 0 at 936 and 560; tsc, eslint, npm test (1440 pass) clean. Example pinned (172).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
… and survival by ctDNA detected

A new view kind. A sample is plasma when its `attribute` (default SAMPLE_CLASS) is one of
`plasma` (default cfDNA, ctDNA) or it sits on one of `plasmaPanels` (e.g. ACCESS129), tissue
when the attribute is one of `tissue` (default Tumor). Among patients with both, per gene
covered by BOTH kinds' panels (the paired view's counting, through the shared sideCountsTail
with a = tissue, b = plasma): altered in both, tissue only, plasma only, neither; positive
percent agreement both ÷ altered-in-tissue with a 95% Wilson interval (new wilsonInterval in
lib/stats.ts, checked against statsmodels' values), and overall concordance. A gene list (≤ 30)
or the `top` (default 15) most altered. ctDNA detected = any alteration (or, with measure
"driver", an OncoKB oncogenic one) called in a plasma sample, as a share of plasma patients with
a Wilson interval; overall survival by detected vs not, Kaplan–Meier plus log-rank (stratified
by study when pooled), from aggregate (detected, months, dead) counts, drawn when each group has
≥ 5 patients, else the reason is shown. `withinDays` keeps only samples the timeline dates
(first Sample acquisition/Sequencing/Specimen event naming them) within N days of a sample of
the other kind, and the result reports how many samples were dated. The public cfDNA studies
carry no sample events, so there it refuses with the counts — noted in the caveat path.

Caveat: pairing by patient not date (unless withinDays), clonal haematopoiesis behind plasma-
only calls, low shedding behind tissue-only, OS from the study's own origin (possible immortal
time when the draw is later). Refusals: limits, bad values, a value on both sides, driver without
a pin, no patient with both (listing the attribute's values).

Verified on nsclc_ctdx_msk_2022 (gallery example, ten NSCLC genes): 1,127 patients, 1,119 with
plasma, 690 tissue, 682 both; PPA EGFR 64% [58–70], KRAS 64%, TP53 68%, STK11 30%, KEAP1 31%,
MET 29%; ctDNA detected 759/1,119 (68%, 65–71%); OS median 21.8 vs 41.5 months, log-rank
p = 2.2e-12. msk_access_2021 with plasmaPanels ACCESS129 and drivers: 449 pairs, EGFR PPA 79%,
CDKN2A 12% (deletions rarely seen in plasma), detected 70%, log-rank p = 0.0002. Figure lint 0
at 936 and 560; tsc, eslint, npm test clean. Example pinned (1,127).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…alteration burden (DISCOVER)

A new view kind, after Canisius, Martens & Wessels (Genome Biol 2016). One cohort, 2–20 genes
(more is refused with the limit named), over the cohort's samples with a gene panel (every sample
of each patient). The database returns only aggregates: samples grouped by panel signature,
stratum, burden r (altered genes among those its panels cover) and a bitmask of which listed
genes are altered; per stratum and gene the altered-sample counts; and each panel signature's
covered genes.

Background: p_ij = σ(a_i + b_j) over the genes sample i's panels cover, fitted over the WHOLE
altered-gene matrix (e.g. 410 genes in NSCLC) so every sample's expected burden is its observed
one and every gene's expected count its observed one — alternating 1-D Newton steps on rows and
columns until the largest relative margin residual is below 1e-4 (reported, with a loud caveat
when it does not converge); never/always-altered rows and genes sit at ∓∞. Samples sharing
(signature, burden) share a row parameter, which is what keeps the data aggregate. Fitted per
study and, with `stratifyBy` (e.g. CANCER_TYPE), per stratum. For each pair, over samples
profiled for both: the co-altered count against the Poisson-binomial with p_i = p_iA·p_iB —
lower tail exclusivity, upper tail co-occurrence, BH per direction. New poissonBinomialTails in
lib/stats.ts: exact DP over counts 0…x+1 (the last state "more than x", so both tails keep their
precision) while trials·(x+2) ≤ 5e6, else the refined normal approximation, flagged per pair.
Beside it the plain two-sided Fisher test on the same samples, BH'd, as the cooccurrence view
runs it. `measure: "driver"` counts only OncoKB oncogenic calls.

Figure: lower-triangle gene × gene heatmap, hue = direction, shade = DISCOVER q (outlined when
q < 0.05, q printed when it fits, a never-co-profiled pair drawn empty), then a table of the 12
most significant pairs, DISCOVER beside Fisher.

Unit tests: the Poisson-binomial tails on a hand-checked case (p = ½, ½, ⅕) and against the
binomial (scipy binom.cdf(280, 1000, 0.3) = 0.08858) for both the exact and approximate paths;
the fit reproducing every margin on a two-panel toy matrix; the figure (lint clean at 560/936).

Verified on msk_impact_2017 NSCLC (gallery example: KRAS, EGFR, BRAF, STK11, KEAP1, TP53):
1,567 patients, 1,680 samples, fit converged in 3 rounds. KRAS/EGFR 19 together vs 170
expected (q 9.5e-57; Fisher 5.7e-47); STK11/KEAP1 co-occur (159 vs 66, q 6.9e-25). EGFR/TP53:
Fisher co-occurrence q 1.8e-7, DISCOVER 294 vs 300 expected, q 0.45; KEAP1/TP53 Fisher 0.79,
DISCOVER exclusive q 0.001; KRAS/STK11 Fisher 2e-17 → DISCOVER 0.02. Stratified by
CANCER_TYPE_DETAILED (26 strata) the drivers' exclusivity holds (KRAS/EGFR q 2e-62). tsc,
eslint, knip (only tmp files), npm test (1455) clean. Example pinned (1,567).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…atment start

A new view kind, `txendpoints`: the clock starts at an anchored treatment record (timeline
event type Treatment by default, key = value such as SUBTYPE=Immuno or AGENT=PEMBROLIZUMAB,
first occurrence unless LAST) and runs to one of three real-world endpoints (MSK-CHORD,
Nature 2024; GENIE BPC):

- ttnt: the start of the next treatment line after the anchor's own line, lines built only
  by lib/treatment-lines.ts (linesCtes; lineGap default 90, lineClasses default systemic);
  for an anchor no line holds, the first line starting after it;
- tot: the latest stop date of the anchored records in the anchor's line; a living patient
  whose stop lies within ongoingDays (30) of the last follow-up is censored as still on it; a
  patient with an anchored record lacking a stop date is left out and counted (a missing stop
  cannot be told from an ongoing treatment);
- rwpfs: the first progression event after the anchor day (default Diagnosis / PROGRESSION in
  Y, Yes, folded; optional probabilityKey + minProbability floor).

Death (OS_STATUS 1:…) is an event for every endpoint; censoring at the last follow-up,
OS_MONTHS × 30.4375 days on the timeline clock. That clock relation was established on the
data: in msk_chord_2024 no patient (0 of 24,950) has a timeline event more than 31 days after
OS_MONTHS × 30.4375 (99th percentile of last-event-day minus that: −0.07 days). The view
re-checks it on every run (a `late` flag per patient in the SQL) and refuses a study where
more than 5% of anchored patients break it; the caveat states the rule and the count. Left
truncation as in the survival/incidence views (an anchor before day 0 enters at day 0).

SQL: txEndpointCountsSql (lib/federation-views.ts) returns only (kind, entry, t, ev, late, n)
tallies through viewAggregate, so it is aggregate-safe ("local" coverage); added to the
boundary suite (three variants). KM per cohort (≤6) with number at risk, medians with 95%
intervals (new kmMedianCi in lib/stats.ts: Greenwood band on the log scale, R survfit's
default; unit-tested against survfit's 1..10 case: median 5, lower 3, upper NA), k-group
log-rank stratified by study (refused below 10 patients per cohort, as survival does). Refuses
a missing anchor listing the key's values with patient counts, and a bad clock. The KM panel
with its risk table is a new shared figure module, lib/figures/km.ts (added to the
kind-boundary list), so later views reuse it.

Registered in types, runners, figures, coverage, help, result text, MCP description, DSL
reference (row, grammar, gallery example), kind card (endpoint, line gap, anchor), and
PREVIEW_KINDS.

Verified on the gallery example (msk_chord_2024 NSCLC, KRAS mutant 2,194 vs wild-type 5,540,
from first SUBTYPE=Immuno): 981 / 1,651 anchored; rwPFS median 2.6 mo (95% CI 2.2–2.7) vs 2.6
(2.4–2.8), log-rank p = 0.98; TTNT medians 11.6 (10.5–12.7) vs 12.0 (11.2–12.7); TOT 3.2
(2.8–3.7) vs 3.2 (2.8–3.5). Figure linted 0 problems at 936 and 560 px. tsc, eslint, knip,
npm test (1443 pass) and docs:check (all examples) clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
A new view kind, `predictive` (literature: PMIDs 29122777, 32913971, 39499893). One or two
cohorts' patients, pooled (baseline hazard stratified by cohort and study when there are
several); a treatment exposure defined by an anchor (any timeline event of that type and
key = value: exposed; the rest unexposed); a gene list (≤20); endpoint OS by default, with the
cox view's monthsAttribute/statusAttribute escape hatch; optional clinical covariates (≤6)
coded by lib/views/covariates.ts buildCoxColumns (numbers as they are, categories against
their commonest level, as the cox view codes clinical axes); measure any or OncoKB driver.

Per gene, Cox (Efron) on gene + exposed + gene×exposed (+ covariates), over the patients
whose panel covers the gene — panel-aware exactly as the riskmodel view: the rows come from
riskmodelRowsSql (panels, mutated genes, covariates; its anchor flag gives exposure) through
PatientLevelResolver.viewRows, the panels' genes from riskmodelPanelGenesSql, and a patient
not covered is left out of that gene's fit, never wild-type. The gene's HR among the exposed
and among the unexposed are the same model's two contrasts — it is fitted with the
interaction coded as gene×exposed and as gene×unexposed (same likelihood), so the gene's own
coefficient is each contrast in turn — and the interaction HR, its Wald p and a
Benjamini–Hochberg q over the genes fitted are reported. A gene with fewer than minMutated
(5) mutated patients in either group, or a fit that runs to infinity, is listed with why,
not fitted. Figure: a two-marker forest (new dualForestSvg in the shared lib/figures/forest.ts)
with the interaction p · q column. The caveat names immortal-time bias (exposure defined
from the whole record, after baseline) and confounding by indication. Coverage "patients".

Verified on the gallery example (msk_chord_2024 NSCLC, 7,809 patients, 2,666 with a
SUBTYPE=Immuno record, 3,975 deaths; every gene covered for every patient): STK11 HR 1.48
treated / 1.27 untreated (interaction p 0.084), KEAP1 1.41 / 1.66 (p 0.051), KRAS 0.99 / 0.89,
TP53 1.02 / 1.72 (interaction HR 0.59, p 4.5e-16, q 2.7e-15), EGFR 1.04 / 0.90, SMARCA4
1.14 / 1.88 (q 1.1e-5). Directions match the checkpoint-inhibitor literature (STK11/KEAP1
adverse on IO). Unit test: on simulated data with a planted interaction the exposed HR equals
the unexposed HR × the interaction HR to 1e-6 and the interaction is found (p < 1e-4).
Figure linted 0 at 936 and 560 px; tsc, eslint, knip, npm test (1445) and docs:check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…or the search

A new view kind, `cutpoint` (Samstein 2019, PMID 30643254; PMID 38949888); the survival view
is untouched. One cohort; a numeric clinical attribute (default TMB_NONSYNONYMOUS) read as the
patient's value or, for a sample attribute, the value of the patient's first sample by sample
id (argMinIf over sample_unique_id, as riskmodelRowsSql reads covariates); endpoint OS with
the monthsAttribute/statusAttribute escape hatch.

Method: candidate cuts are the marker's distinct values leaving between 10% and 90% of the
patients at or below them, at most 100 (evenly spaced among them). At each, the maximally
selected standardized log-rank statistic (Lausen & Schumacher 1992; R maxstat's LogRank):
log-rank scores (event minus Nelson–Aalen hazard), summed over the low group, squared over
their permutation variance. The maximum is the chosen cut (high = marker > cut). Corrected p:
(1 + hits)/(1 + B) over B = 1000 seeded (seed 1, mulberry32) shuffles of the scores over the
marker order, each taking its own maximum; the 95th percentile of that maximum is drawn as
the significance line. Reported beside it: the ordinary log-rank p at the cut (which ignores
the search), the high-vs-low Cox HR, both groups' KM and medians. Rows are sorted canonically
before the shuffles, so the seeded p does not depend on row order. Optional stratifyBy: a
scan per value (the largest 8 with at least minStratum = 100 patients; the rest counted), as
a table. Caveat: optimistic in-sample choice, needs validation; TMB not comparable across
panels when studies are pooled.

Rows: new cutpointRowsSql (values only, through PatientLevelResolver.viewRows; boundary suite
covers it); coverage "patients". Figure: statistic-vs-cut curve with the chosen cut marked and
the permutation 95% line, KM at the cut via the shared lib/figures/km.ts, the strata table.

Verified on the gallery example (tmb_mskcc_2018, 1,661 ICI-treated patients, 832 deaths, 73
candidate cuts): best cut TMB > 14.4 (322 high), HR 0.45 [0.36–0.55], medians 47 vs 15 mo,
corrected p = 0.001 (permutation 95% line χ² 8.25 against a maximum of 67.8), naive p 9.5e-15.
Per cancer type: NSCLC 18.6 (p 0.001), melanoma 9.8 (0.001), colorectal 11.1 (0.004), head and
neck 8.8 (0.028), bladder 14.7 (0.12), renal 1.1 (0.44), oesophagogastric 10.0 (0.48), glioma
2.9 (0.62); 3 values / 133 patients not scanned. On msk_impact_2017 (not ICI-treated) the
direction reverses (HR 1.47), as expected without immunotherapy. Unit tests: a planted
threshold at 30 is found with corrected p < 0.01, a null cohort is not significant after
correction, candidates respect the window and cap, seeded reruns agree, and under a modest
effect the statistic matches the log-rank χ² within 10% (on the real example 82.18 vs 82.50).
Figure linted 0 at 936 and 560 px; tsc, eslint, knip, npm test (1452), docs:check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…andom-effects pooled

A pan-cancer forest / meta-analysis view. The exposure is a plot axis: a gene's alteration
(exposed = altered; a patient not profiled for the gene is left out by coxRows' own
panel-aware rule, never counted as unaltered), a categorical attribute with `exposed` naming
the level, or a numeric one with `threshold`. Rows are a categorical attribute (default
CANCER_TYPE; patients whose samples disagree, "Multiple", are left out and counted), the
study, or the view's cohorts. Within each row a Cox model (Efron ties, lib/stats.ts) is fit
for exposed vs not, univariate or adjusted for up to 4 covariates coded by
lib/views/covariates.ts as the cox view codes them. Strata under minPatients (30) patients
or minEvents (10) events, and past the maxStrata (30) largest, pool into one disclosed
"Other" row fit with a baseline hazard per stratum (or, small:"drop", are listed as left
out); a row without an exposed and an unexposed event, or whose fit does not converge, is
listed with the reason. Rows are pooled by inverse variance on log HR: fixed effect and
DerSimonian–Laird random effects (new dersimonianLaird in lib/stats.ts) with tau², I²,
Cochran's Q and its p. Endpoint OS by default, with the cox view's months/status escape
hatch. Per-patient rows come only from patient-level participants (coverage "patients").

Figure: metaForestSvg in the shared lib/figures/forest.ts — n (events), HR [CI], each row's
random-effects weight (square sized by it), and the two pooled diamonds.

Refuses: no exposure, a numeric exposure without a threshold, a categorical non-alteration
exposure without `exposed` (listing its levels), an unknown level, a numeric strata
attribute, strata "study" over one study or "cohort" over one cohort, more than 4
covariates, bad bounds.

Verified on the gallery example (msk_impact_50k_2026, TP53 vs OS by CANCER_TYPE): 48,179
patients, 43,375 with OS, TP53 status and one cancer type (16,953 deaths); 4,135 without
OS, 669 "Multiple" left out; 30 rows (29 cancer types + Other of 34). HRs > 1 almost
everywhere (prostate 2.50, endometrial 2.88, thyroid 4.06), < 1 in glioma 0.86 and
melanoma 0.78 (as expected: TP53 marks IDH-mutant glioma); random effects 1.63
(1.42–1.87), fixed 1.54 (1.49–1.59), tau² 0.121, I² 93%, Q 431 on 29 df. Figure lints
clean at 936 and 560 px. DerSimonian–Laird checked against hand-computed values in
test/forest.test.ts, with the row building (pooling, drop, unestimable rows).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
alisman and others added 19 commits October 23, 2026 12:41
…ween two cohorts

Every gene mutated (called mutations; OncoKB drivers with measure:"driver") in at least
minShare (default 5%) of the patients profiled for it and in at least minAltered (10), both
cohorts together, 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, coded by lib/views/covariates.ts as cox/logistic code theirs. Reported per gene: the
cohort term's adjusted OR, Wald 95% CI, p, and BH q over the genes fit, beside the raw OR and
two-sided Fisher p on the same patients. Patients, not samples, and panel-aware per gene: a
patient is in a gene's model only when one of their mutation panels covers it (or WES), never
counted as wild type. Rows come from a new adjustedRowsSql (one row per patient: panels,
mutated genes, covariate values; no identifier; run only through viewRows, added to the
boundary suite) and the panel genes from riskmodelPanelGenesSql.

A covariate category in which no patient (or every patient) is mutated would separate; for
that gene it is merged into its reference and the caveat counts the genes affected. A gene
whose fit still does not converge, or has an empty 2×2 cell, is listed in `failed`, never
dropped. Refuses: not exactly two cohorts, >6 covariates, a categorical covariate with >30
values, more than 200 genes past the threshold (naming the count and asking for a higher
minShare), no OncoKB pin for drivers, empty cohorts.

Figure (lib/figures/adjusted.ts): the shared volcano of adjusted log2 OR against −log10 q
(volcano gains an optional pName for its y title; unset, every other volcano is unchanged),
genes at q < 0.05 labelled, then the top 15 genes raw beside adjusted.

Verified on the gallery example (msk_impact_50k_2026 colorectal, AGE_AT_DX < 50 vs >= 50,
adjusted for MSI_TYPE and TMB_SCORE): 1,699 vs 3,149 patients (1,693 / 3,146 with both
covariates); 56 genes tested, all fit (9 with a separating MSI category merged), 4 at
q < 0.05 — BRAF 7.4% vs 12.9% (raw OR 0.54, adjusted 0.52, q 4e-7), TCF7L2 1.40, TP53
77% vs 72% (1.31), RNF43 0.66 — matching the early-onset CRC literature; APC and SOX9 are
nominal raw and not after adjustment. Lints clean at 936 and 560 px. Unit tests: a
confounded gene's adjusted OR returns to 1 while the raw one does not; unprofiled patients are
left out of the gene; an unfittable gene is listed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
… and losses from segments

A new view kind. From `copy_number_seg` (hg19 only: a study on another build is refused naming
each study's build, as the genome view does), computed per sample inside the database:
- FGA: the share of the sample's segmented length (autosomes; X with includeX) whose |segment
  mean| is at or past `threshold` (default 0.2, log2);
- arm calls: segments split at hg19's centromeres (new HG19_CENTROMERES in
  lib/federation-views.ts, the p11.1/q11 boundary of UCSC's hg19 cytoBand, source cited), the
  acrocentric p arms of 13/14/15/21/22 left out (39 arms, as Taylor et al. 2018); an arm is
  gained (lost) when more than `armShare` (default 0.5) of its length is past ±threshold, and
  measured when segments cover that share;
- aneuploidy score: arms gained or lost.
Only a joint histogram (FGA in 1-point bins × score × arms measured) and per-arm measured /
gain / loss counts leave the database. Per cohort (≤ 6): quartiles and 5th/95th percentiles from
the histograms, Mann–Whitney (2 cohorts) or Kruskal–Wallis (3+) on each (lib/stats.ts, from
histograms), and each arm's gain/loss share of the samples measuring it.

Figure: aneuploidy score and FGA as horizontal boxes per cohort (side by side when wide,
stacked when narrow), the rank test, then an arm × cohort heatmap (gain strip above loss strip,
shade relative to the most frequent call, named in the title). Caveat: every sample of each
patient counts, uncovered arms are not called (lowering the score; counted per cohort), panel
segments are noisy and purity is not corrected.

Verified on msk_impact_50k_2026 (gallery example): colorectal 5,390 samples scored, median score
7 [2–12], FGA 15% [4–28], 20q gained 60%, 13q 42%, 18q lost 53%, 17p 36%; breast 6,906, 1q 45%
and 8q 41% gained, 16q 39% lost; pancreatic 2,910, median 2 arms (low purity). Kruskal–Wallis
score p = 2e-163, FGA p = 1.2e-222. msk_impact_2017 colorectal alone gives the same arms (20q
55%, 18q 56%). Mixing hnsc_a5consortium_2025 (hg38) with msk_impact_2017 is refused, naming
both builds. Figure lint 0 at 560/936 (unit test also 1352); tsc, eslint, npm test (1459) clean.
Example pinned (4,993 / 5,803 / 2,729).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
… cohort

`validateOn` names a second cohort of the document (literature: PMIDs 37477937, 38488807). The
ensemble is fitted on the view's cohort exactly as before (the fit, its cache key and every
reported number are unchanged; validateOn is a presentation key, like describe, so it never
refits). Then every patient of the validation cohort — one patient-level study, any study —
is read with the fit's own row query (riskViewRows: same endpoint, anchor, strata, measure),
kept by the same keepRiskRows rule, and scored with the ensemble's mean coefficients: each
gene's coefficient averaged over every run, zero where a run dropped it (selected × mean
where kept) — the average the existing `validate` half is scored with. Coverage rule: a
validation patient whose panels do not cover every gene the model weighs (non-zero ensemble
coefficient) is left out and counted, never scored as wild-type. Patients are cut at the
training risk-group thresholds (the groups' `from` raw scores) and reported with Harrell's
C-index (lib/coxnet.ts concordance, within strata) and a seeded bootstrap 95% interval (200
resamples), KM per group, the k-group log-rank (stratified by the model's strata) and each
group's Cox HR against the lowest. Refused: a cohort not in the document, the fitted cohort
itself, a multi-study or aggregate-only cohort, and a model with covariates (their coding is
not carried to another cohort yet). The figure draws the validation's KM panel (shared
lib/figures/km.ts) and its C-index / log-rank line below the existing figure; the caveat
names the thresholds, the coverage exclusions, and that patients of two studies cannot be
de-duplicated. New module lib/views/riskmodel-validate.ts; riskViewRows and RiskResolver
exported from lib/views/riskmodel.ts.

Byte-identical without validateOn: the existing riskmodel gallery example ("A survival risk
model from mutated genes in lung cancer") was run before the change and after it, with
`ms`/`renderMs` stripped from the JSON — identical (113,978 characters; a second run before
the change was identical too, so the comparison is deterministic), and its SVG at 936 and
560 px is identical as well.

Verified on the gallery example (fit on msk_impact_2017 NSCLC, 1,440 patients, 468 events,
10 runs, held-out C-index 0.613; validate on msk_chord_2024 NSCLC): 7,809 scored, 0 not
covered (21 weighted genes), C-index 0.607 (95% bootstrap CI 0.597–0.616); low / intermediate
/ high risk n 2,790 / 3,237 / 1,782, medians 66.4 / 33.3 / 20.7 months, HR 1.63 [1.51–1.76]
and 2.12 [1.95–2.31], log-rank p 8.1e-73. Unit test with a stub resolver: scores by
selected × mean coefficient, the training cut, an uncovered patient left out. Figure linted 0
at 936 and 560 px; tsc, eslint, knip, npm test (1453) and docs:check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…rimary vs metastasis concordance

Each patient with a sample sequenced for mutations, at their highest sensitive therapeutic
level (1, 2, 3A, 3B, 4, or none) over the called mutations and high-level copy-number calls
in any of their samples, read from the version-pinned OncoKB store (oncokb_annotation.
variant_annotation at the run's pin, echoed in the result and caveat), and separately whether
any alteration carries a resistance level (R1, R2). Structural variants are matched as
driverJoin matches them, but the store holds no SV level at v7.5; the caveat names the types
the store does hold (actionStoreTypesSql). Per cohort (≤4) and, with groupBy, per group of a
categorical attribute per patient ("Multiple" when samples disagree, the `top` (8) largest
drawn and the rest pooled into a named Other). For patients with a sequenced sample on each
side of pairBy (default SAMPLE_TYPE Primary / Metastasis, case-insensitive; null turns it
off), the 6x6 cross-table of their highest level per side and the discordant share.

All counts in SQL (actionLevelsSql, actionConcordanceSql; boundary suite covers both),
through viewAggregate, so coverage is "local". The store is pre-filtered to rows with a level
(a few thousand of its millions) before the join: 55 s -> 5 s on msk_impact_50k_2026.

The store's level is its highest for the alteration in ANY cancer type, not the patient's
own, so the shares are an upper bound; the caveat, the axis title and the gallery note say so.
"No level" means none on the genes sequenced; an unprofiled gene is unknown.

Verified on the gallery example (msk_impact_50k_2026 by CANCER_TYPE): 48,179 patients, all
sequenced; Level 1 27,154 (56%), 2 1,527, 3A 3,244, 3B 0 (no 3B in the store), 4 3,061,
none 13,193; R1 9,698. Pancreatic 87% level 1 and 83% R1 (KRAS, levels not tumour-specific —
the upper-bound effect), prostate 38%. 2,278 patients with both a primary and a metastasis:
581 (25.5%) discordant, diagonal 1,108 / 32 / 84 / 85 / 388. Lints clean at 936 and 560 px.
Unit test of the top-N pooling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…anonical driver

A new kind. Within one cohort, each patient sequenced for mutations is driver-positive with
an OncoKB driver (oncogenic or likely oncogenic at the pin, via driverJoin: a called
mutation, a high-level copy-number call or a structural variant) in a canonical driver list
(default the NSCLC set EGFR, KRAS, ALK, ROS1, BRAF, MET, RET, ERBB2, NTRK1-3; user-editable,
up to 40 genes); else driver-negative when a mutation panel of theirs covers every listed
gene (or WES); else "not fully profiled" — counted and kept out of both groups, never
driver-negative. Then every other gene's alteration (mutation by default; sv counts either
fusion partner; cna) is compared between driver-negative and driver-positive patients, in
patients over those profiled for the gene (the enrichment view's panel-aware construction:
panel samples per panel joined to their genes plus whole-exome, an off-panel call counting as
measured), two-sided Fisher, BH q, log2 of the negative share over the positive one.

All counts in SQL (dnegGroupsSql, dnegAlteredSql, dnegProfiledSql over one shared CTE chain;
boundary suite covers each), through viewAggregate, coverage "local". Figure
(lib/figures/drivernegative.ts): the group split as one bar, the shared volcano
(driver-negative to the right), and the genes most enriched without a driver.

Refuses: not exactly one cohort, no OncoKB pin, >40 drivers, a bad symbol, an unknown
alteration, a cohort where one group is empty (naming the three counts).

Verified on the gallery example (msk_chord_2024 NSCLC): 7,809 patients, 4,911 (62.9%)
driver-positive, 2,898 (37.1%) driver-negative, 0 not fully profiled (IMPACT panels cover all
eleven). 487 genes tested, 95 at q < 0.05: NF1 12.4% vs 5.0% (q 3e-28), KMT2D, TP53 59% vs
47%, NFE2L2 5.6% vs 1.6%, CDKN2A, RASA1, PTEN, KEAP1, MAP2K1 1.8% vs 0.5%, NRAS — the
alternative MAPK and NRF2 routes expected without a canonical driver; RBM10 goes with the
drivers. With alteration "sv": 114 genes, NFE2L2 rearrangements 17 vs 2 (q 4e-4). 2.3 s.
Lints clean at 936 and 560 px. Unit test of the per-gene counting (WES denominators,
off-panel calls, untested genes, Fisher, BH order).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
The merged-row tooltip check hovers the label gutter at fixed offsets from the canvas, and the
drag-to-cohort check presses 40px into it; a pointer past the bottom of the viewport never
lands. The oncoprint sits below the add-view panel, which grows by a row with every few view
kinds; with the preview kinds it pushed the canvas to the window's edge (top at 828 of 1000px)
and these checks failed while master passed. Scrolling the canvas to the top first makes them
independent of what sits above it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
lib/new-in-lab.ts lists the view kinds the portal does not draw in any form: the regressions
(cox, logistic, the risk model and the new outcome views), the timeline views (course,
sequence, incidence, labs, attrition), the new association views, and the sample- and
genome-level views (paired, acquired, ctdna, clonality, genome, instability, composition).
Kinds the portal has in some form — its Comparison, Plots, Mutations, Pathways and patient-view
tabs, the oncoprint, survival, co-occurrence, co-expression, cancer types summary, generic
assays, differential expression — are left out. The add-view buttons and the Examples tab's
cards (any example drawing such a kind) show a green NEW after the name, titled "not in
cBioPortal": 23 of 42 buttons and 29 of 82 examples on this branch, checked in a browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
The mark's text takes its own colour token (--new: orange, a lighter orange in dark mode), so
it stands apart from the page's teal and grey without a fill. The Examples tab's filter chips
carry it too: a view chip for a NEW kind, and a topic chip when at least one example under it
draws a NEW view (23 of 42 view chips and 55 of 101 topic chips on this branch, checked in a
browser).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
The view and topic filters were rows of outlined pills, which with a NEW mark beside many of
them read as a wall of boxes. Each is now a list of plain links flowing down columns (11rem
wide, as many as the card fits): the name with its first letter capitalised (by CSS, so the
text and what e2e matches are unchanged), its example count, and NEW where it applies; the
chosen one is bold and underlined, and clicking it again clears the filter. The two lists'
headings are bold ink over a rule. The "all" chip beside the search stays as it was.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
The add-view pills were white outlines on the white card; with 42 of them, many carrying NEW,
they ran together. A kind not yet in the document now sits on the border grey with ink text and
no outline, so each pill reads apart from its neighbours; one already in the document keeps
its teal fill, and one this study cannot draw stays faded with its reason in the title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…ion to death

A new view kind, `multistate` (Rueda et al., Nature 2019, PMID 30867590): three states per
patient on the timeline's clock, ≤4 cohorts.

- Start is the timeline's day 0. In msk_chord_2024 day 0 is first sequencing: every one of
  24,950 patients has a Sequencing event on day 0 and none earlier (Sample acquisition and
  Diagnosis SUBTYPE=Primary sit mostly before it). The view counts patients with a Sequencing
  event on day 0 on every run and names the origin "first sequencing" only when ≥90% do;
  otherwise the caveat says day 0 is the study's own and that later-sequenced patients carry
  immortal time.
- Progression is the first event of type Diagnosis with PROGRESSION in Y / Yes (folded; an
  optional probabilityKey + minProbability floor), the same settings and defaults as
  txendpoints' rwPFS. A progression on or before day 0 starts the patient in the progression
  state (9,111 of 24,950 in msk_chord_2024), or, with prevalent: "exclude", leaves them out.
  A progression on or after the end of follow-up is not counted (and counted in the caption).
- Death / end of follow-up: OS_STATUS / OS_MONTHS, OS_MONTHS × 30.4375 days on the timeline's
  clock, with txendpoints' clock check (a timeline event > 31 days past it; a study over 5% is
  refused).

Markov, clock-forward: every hazard runs on time since day 0, progression → death included,
with delayed entry at the progression. Per cohort: events, person-months and rates per 100
person-months (Poisson 95% interval); Nelson–Aalen cumulative hazards per transition (Aalen
variance, log-scale interval); Aalen–Johansen state occupation from the initial distribution,
drawn as stacked areas to where ≥10 of the cohort remain followed and tabled at horizons
(12/24/60 mo). Per transition a Cox model (Efron) of each cohort against the first, baseline
per study, no ratio for a cohort with < 5 events. Design chosen: cohort vs cohort, so the view
stays aggregate-only ("local" coverage).

Shared code: the txendpoints SQL pieces (OS, last timeline day, progression events, the event
FROM/attribute helpers) are now functions in lib/federation-views.ts that both kinds call, and
the progression defaults/checks/words and the clock tolerance live in lib/timeline-endpoints.ts.
txendpoints is unchanged: its SQL for four variants is byte-identical before/after, and its
gallery example's result JSON and SVGs (936, 560) are byte-identical. lib/stats.ts: CoxRow takes
an optional `entry` (counting-process risk sets); rows without one take the same path as before.
New illnessDeath() estimator. multistateCountsSql returns only (prevalent, tp, te, dead, flags,
n) tallies through viewAggregate; added to the boundary suite.

Refuses: >4 cohorts, bad prevalent/horizons/cutoff, a failed clock check, no follow-up, and no
progression event at all (listing the values the key takes).

Verified on the gallery example (msk_chord_2024 colorectal, stage 1-3 n=3,171 vs stage 4
n=2,372; 937 and 1,004 prevalent; 41 never assessed): start→progression 1.2 vs 6.5 per 100
person-months, HR 4.41 (4.01–4.86); start→death 0.11 vs 0.32, HR 2.63 (1.82–3.82);
progression→death 1.6 vs 2.8, HR 1.70 (1.55–1.86). At 24 months not progressed / progressed /
dead: 47/36/17% vs 9/49/43%. Unit tests: illness-death estimators against a hand computation,
the delayed-entry Cox partial likelihood against brute-force risk sets, the figure linted at
560 and 936. Real figure linted 0 problems at 936 and 560. tsc, eslint, knip, npm test (1510)
and docs:check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
A new view kind, `rmst`, for when hazards are not proportional (immunotherapy curves). The
survival view is unchanged.

Per cohort (≤6): the area under the Kaplan–Meier curve from 0 to τ — the mean months lived in
the first τ — with the Greenwood-type standard error of that area (Klein & Moeschberger; R
survRM2's rmst1: Σ A_j² d_j / (n_j (n_j − d_j)), A_j the area from t_j to τ, 0 where n_j = d_j)
and a 95% interval; against the first cohort the RMST difference and ratio (log scale), each with
a Wald interval and two-sided p (each vs the first for >2). τ defaults to the largest time at
which every cohort still has 10 at risk (floored to 0.1 month, stated per panel); a set τ past a
cohort's last follow-up is refused, naming it. landmarkMonths (≤4, increasing, default [0]):
for each L the patients alive and uncensored at L (t > L), clock reset there, KM and RMST from it;
drawn as small multiples.

Endpoint: OS by default via the resolvers' survivalCounts (the cox/survival monthsAttribute /
statusAttribute escape hatch works too). anchor: OS from a treatment start through txendpoints'
own SQL — txEndpointCountsSql gains endpoint "os" (death alone; the other endpoints' SQL is
byte-identical for all four snapshot variants) — with its clock check and left truncation; the
anchor parsing and the "no patient has the anchor" refusal moved into lib/timeline-endpoints.ts,
which txendpoints now calls (its gallery result JSON and SVGs at 936 and 560 are byte-identical).
lib/figures/km.ts takes an optional shadeTo (area under each curve to τ, a rule at τ); unset it
draws exactly as before (txendpoints' SVGs byte-identical). New rmst() and atRiskAtTimes() in
lib/stats.ts. Only (entry, t, event) counts leave the database; "local" coverage; the "os"
variant is in the boundary suite.

Unit tests: RMST and its SE against a hand computation on a toy KM (events 1, 3, 4; censored 2,
5; τ 4.5: area 3.2667, SE 0.5830); no censoring equals mean(min(t, τ)); delayed entry; contrast
intervals; landmark reset; figure lint at 560 and 936. survRM2's own example data (PBC) is not
available offline, so it is not reproduced.

Verified on the gallery example (tmb_mskcc_2018, TMB < 10 n=1,201 vs ≥ 10 n=460, τ = 24):
RMST 14.5 (13.9–15.0) vs 17.7 (16.9–18.6) months, difference +3.3 (2.3–4.2), p = 1e-10,
ratio 1.23 (1.15–1.30); from 6 months (758 vs 321) 16.0 vs 19.6, +3.6; from 12 months (503 vs
241) 17.4 vs 20.6, +3.2. Anchored run (msk_chord NSCLC from first SUBTYPE=Immuno, KRAS mutant
981 vs wild-type 1,651 anchored, as txendpoints): default τ 81.5 months, RMST 27.9 vs 25.5.
Refusals checked: τ = 500 (names both cohorts' last follow-up), an unknown anchor value (lists
SUBTYPE's values). Figures lint 0 at 936 and 560. tsc, eslint, knip, npm test (1520) and
docs:check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…l by each

A new view kind (Chowell et al. Science 2018; McGranahan et al. Cell 2017). Per patient, from one
viewRows query (no identifiers; patient-level studies only, view-coverage "patients"): whether
each of HLA-A, -B, -C carries two alleles that match at `resolution` fields of the allele name
(default 2, protein level; 1 = allele group, "full" = every field). Allele names are normalised
in SQL so hla_a_02_01_01_01 (MSK-IMPACT), A*02:01 (nsclc_mskcc_2018) and A0201 (the melanoma
studies' "A0201/A2402" pairs) all read A*02:01. The default attribute layouts cover all three
(HLA-A1/HLA-A2, HLA_A1_ALLELE(S), HLA_A); `alleles` names others. HLA LOH: any of `lohAttributes`
(default HLA_A1_LOH … HLA_C2_LOH, HLA_LOH) equal to one of `lohValues` (Loss/Yes/LOH/True) in any
sample; a patient with no call anywhere is left out of that split.

Per cohort: patients typed at all three loci homozygous at 0/1/2/3 loci, per-locus homozygosity,
patients typed at fewer fields than asked, LOH share among patients with a call. Then the
endpoint (default OS; monthsAttribute/statusAttribute) by homozygous at >=1 locus vs heterozygous
at all, and by HLA LOH vs none: KM, log-rank and Cox HR (Efron), stratified by study when pooled,
drawn when each group has >= 5 patients, else the reason. `covariates` (<= 4) add an adjusted HR
(numbers as they are, categories as indicators against the commonest, past 10 levels pooled).
Supertypes and HED are not computed (said in the caption). msk_impact_50k_2026 has no treatment
timeline, so there is no immunotherapy anchor: the caption says OS runs from each study's origin,
and that outside treated patients homozygosity reads prognosis, not response; and that LOHHLA calls
only heterozygous loci, so the LOH split is not independent of the genotype. A study without
alleles is refused listing its HLA-named attributes (msk_chord_2024 has none — the internal
studies will refuse the same way).

Verified, gallery example 1 (msk_impact_50k_2026 NSCLC, adjusted for TMB_SCORE): 6,633 patients,
5,901 typed; homozygous at 0/1/2+ loci 78.2/16.1/5.8% (A 12.0%, B 6.6%, C 10.3%); OS hom vs not
HR 1.02 [0.92–1.12], log-rank p 0.76, adjusted 1.01. HLA LOH 1,239 of 4,158 with a call (29.8%);
OS median 32.0 vs 47.5 months, HR 1.35 [1.22–1.50], p 7.6e-9, adjusted 1.31. Example 2 (skcm_dfci_2015
+ skcm_mskcc_2014 + mel_ucla_2016, all on checkpoint blockade): 212 typed, 22.2% homozygous; OS
median 12.1 vs 21.7 months, HR 1.22 [0.82–1.82], stratified by study — Chowell's direction;
no LOH calls, so that split says why. Figures lint 0 at 936 and 560; unit test of the counts,
split, covariate coding and figure; tsc, eslint, npm test (1507) and docs:check clean; both
examples pinned (6,633; 212).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…alls, with before/after

An option on the ctdna view (Razavi et al. Nat Med 2019; MSK-ACCESS with matched normal, Brannon
et al. 2021). With `chFilter: true` (or {genes, normal, normalMinAlt, normalMinRatio}), plasma
mutation calls likely from clonal haematopoiesis are left out of every count — agreement per gene,
ctDNA detected and survival by detection:
- by gene: DNMT3A, TET2, ASXL1, PPM1D, JAK2, SF3B1, SRSF2, U2AF1, CBL, GNB1, GNAS, IDH2 (the CH
  genes of Razavi 2019 / Bolton et al. Nat Genet 2020). TP53, ATM and CHEK2 are CH genes too but
  left off by default because their plasma calls are as often the tumour's; `genes: []` for none.
- by the matched normal (default on): mutation_derived's normal counts, where loaded — at least
  normalMinAlt (2) alternate reads in the white-cell sample at an allele fraction at least
  normalMinRatio (0.3) x the plasma's. The ratio is from the data: of ACCESS plasma calls with
  >= 2 normal reads, 126 of 210 non-CH-gene calls have a normal VAF under a tenth of the plasma's —
  tumour DNA in the buffy coat (EGFR, KRAS), which `normalAltCount > 0` alone would have removed.
Copy-number and structural calls and tissue are never filtered. Implemented as a `chx` CTE of the
plasma samples' removed calls (genomic_event_derived joined to mutation_derived on sample, gene and
protein change — all 7,283 ACCESS calls match) and an exclusion condition passed to the shared
sideCountsTail and to the detected CTE; both take it as an optional argument that adds nothing when
absent. The result adds per panel `ch` (calls, with normal counts, removed by gene / by normal /
either, patients, removed calls per gene) and `before` (the unfiltered detected share, plasma-only
and both per gene, the OS curves' medians and log-rank p, from a second run of the same queries
without the rule on the same genes); the figure adds a line and a "plasma-only unfiltered" column,
the caption the rule, the counts and its biases. preview-kinds' ctdna entry names the filter.

Unset is byte-identical: the existing gallery example (nsclc_ctdx_msk_2022) and msk_access_2021
with plasmaPanels, run before and after this change — result JSON (queries and timing aside), the
SVG at 936 and 560 px, and the sorted SQL of every query — compare equal with cmp.

Verified, new gallery example (msk_access_2021, plasmaPanels ACCESS129, chFilter true): 83 of 2,024
plasma mutation calls removed in 51 patients (41 by gene: SF3B1 14, DNMT3A 11, GNAS 8, IDH2 4; 44 by
the normal, TP53 14); ctDNA detected 453/617 (73.4%) vs 457 (74.1%) unfiltered; OS log-rank p 3.4e-4
vs 3.5e-4; removed calls are plasma-only (AR 12 -> 11, RB1 10 -> 8, ATM 17 -> 16) while every both
call stays. nsclc_ctdx_msk_2022: 842 of 2,377 plasma calls have normal counts (the ctDx panel has
none); 22 removed in 20 patients, detection 759 unchanged, MET both 25 -> 24. Figures lint 0 at 936
and 560; tsc, eslint, npm test (1512) and docs:check clean; example pinned (617).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
… organ

A new kind (MSK-MET, Nguyen et al. Cell 2022; LUAD organotropism, PMID 37084736). Within one
cohort, metastatic sites come from whichever source it carries (source auto, or chosen):
- flags: per-organ Yes/No attributes with a prefix (default DMETS_DX_, MSK-MET's); a patient is
  evaluable for a site when its flag is recorded Yes or No;
- timeline: event type / key naming a site (default Diagnosis / TUMOR_SITE, folded through
  lib/event-names.ts), at any date; a patient with any site recorded is evaluable for all;
- sample: a biopsy-site attribute (default METASTATIC_SITE), free text mapped to organs by the
  view's own regex rules (ORG_SITE_MAP); unmatched values become "Other" and are listed in the
  caveat with their patient counts. Every patient is evaluable ("site sampled, not every site").
With none, the view refuses naming what the cohort has under each source.

Per site × gene, over patients evaluable for the site AND profiled for the gene (panel-aware:
a mutation/CNA/SV panel listing it, or WES; off-panel calls in neither side), the 2×2 of
metastasis there × altered (default OncoKB-driver mutations at the pin; alteration and measure
settable) per stratum of stratifyBy (default CANCER_TYPE per patient, "Multiple" when samples
disagree; null for none). Test: Cochran–Mantel–Haenszel (lib/stats.ts, continuity-corrected)
with the Mantel–Haenszel common OR; BH over the grid; a cell needs minPatients (5) altered, with
and without the site. Genes: a list (≤20) or the top (12, ≤20) most altered. Sites ≤30, refused
past that. With the timeline source, also the cumulative incidence of the first event at one
site after day 0 by one gene's status (altered vs profiled-not-altered), death competing:
incidenceCountsSql over each half of the cohort, Aalen–Johansen, Gray's permutation test; patients
with the site by day 0 are excluded and counted; the panel is withheld (noIncidence) when >5% of
records have the site after their last follow-up (the clock check). Default site/gene: the
grid's most significant cell.

All SQL in lib/federation-views.ts returns counts only (orgSourcesSql, orgSitesSql,
orgOtherValuesSql, orgTopGenesSql, orgCountsSql, orgIncidenceSql; added to the boundary suite);
coverage "local". Figure: gene × site heatmap of the log2 MH OR (diverging, clamped at OR 2,
outlined at q<0.05), the ten strongest cells, and the incidence curves through the shared km
panel, which gains an `origin` option (default 1, so every existing KM figure is unchanged).

Verified on the gallery examples:
- msk_met_2021 pan-cancer (25,775 patients, flags, 27 cancer-type strata): 21 sites × 12 genes,
  252 cells tested, 66 at q<0.05. TP53 with liver (MH OR 1.45, q 1e-30), distant LN, CNS/brain
  (1.42), lung, bone, adrenal; EGFR with CNS/brain (1.63, q 2e-10), bone and PNS; APC with liver
  (1.58) and lung; PTEN and ARID1A with less intra-abdominal spread (0.62, 0.67) — the paper's
  map. 13 s.
- msk_chord_2024 NSCLC (7,809), timeline: TP53 × lymph nodes OR 2.0, EGFR × CNS/brain 1.68,
  SMARCA4/STK11/KEAP1 × adrenal. Incidence of a first CNS/brain site, EGFR-mutant 1,350 vs
  5,107 (411 / 941 excluded with brain sites by day 0): 14.2% vs 12.8% at 12 mo, 32.0% vs 22.7%
  at 60 mo, Gray p 5e-4; 0 of 6,457 records past follow-up. 9 s.
- sample source on the same cohort: 14 organs, TP53 × CNS/Brain OR 2.1, EGFR × pleura 1.9.
Figures lint 0 problems at 936 and 560 px; tsc, eslint, knip, npm test (1,511 pass) and
docs:check (all examples, counts pinned for the two new ones) clean. Unit test of the per-cell
tables and CMH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…, by an attribute, and OS by subtype

A new kind applying a published molecular classifier per patient as derived subtypes. One
classifier so far, `endometrial` (TCGA 2013 / ProMisE / WHO 2020); the `classifier` setting
refuses any other, naming those that exist, and a new one is a CLASSIFIERS entry plus its SQL.
Hierarchy per patient over all their samples, first match wins:
- POLE-ultramutated: a somatic POLE exonuclease-domain hotspot (León-Castillo et al., J Pathol
  2020, PMID 31829442: P286R, S297F, F367S/V, V411L, L424I/V, P436R, M444K, A456P, S459F, M295R,
  D368Y), off-panel and germline calls excluded;
- else MMR-deficient: MSI-high in any sample. msi setting: a categorical call (default MSI_TYPE,
  Instable / Stable) or a score (MSI_SCORE, else MSI_SENSOR_SCORE: MSIsensor ≥ 10 high, < 3
  stable, in between indeterminate); the default is the first the cohorts carry, refused naming
  their MSI attributes and example values when none is. Not MMR IHC (not in the warehouse);
- else p53-abnormal: an OncoKB-driver TP53 mutation at the pin (p53 "nonsynonymous": any
  protein-changing TP53 call);
- else NSMP.
"Not classifiable" (counted with the reason, kept out of shares, tests and curves, never
guessed): not sequenced, not profiled for POLE or TP53 (panel-aware), or no MSI-high/stable call
when the hierarchy reaches MSI. Per cohort (≤6): subtype shares; with `by`, per group of a patient
attribute (top 8, rest pooled into a named Other) with chi-squared; chi-squared between cohorts;
OS by subtype (KM, k-group log-rank, stratified by study) for the first three cohorts.

SQL (lib/federation-views.ts: molCountsSql, molSurvivalSql, molMsiAttributesSql) returns counts
only — classes stay in CTEs; boundary suite covers each; coverage "local". Figure: stacked bars
per cohort and group, and the shared KM panel, which gains a `series` option so a subtype keeps
its colour when another is empty (default its place in the list: existing figures unchanged).

Verified:
- msk_impact_50k_2026 endometrial (gallery): 2,373 patients, 196 not classifiable (no MSI call);
  of 2,177 classified POLEmut 119 (5.5%), MMRd 353 (16%), p53abn 895 (41%), NSMP 810 (37%). By
  ANCESTRY_LABEL p53abn is 71% (137/194) in AFR vs 34% in non-ASJ EUR, χ² 116 (21 df) p 4e-15 —
  the Black/White difference of PMID 37651310. OS: POLEmut 1 death of 112, p53abn median 29.6 mo,
  NSMP 78.2, log-rank χ² 199 (3 df).
- ucec_tcga_pan_can_atlas_2018 against TCGA's own SUBTYPE (second gallery example, MSI from
  MSI_SENSOR_SCORE): UCEC_POLE 48/49 -> POLEmut, UCEC_MSI 122/126 -> MMRd, CN_HIGH 139/161 ->
  p53abn, CN_LOW 135/145 -> NSMP.
Figures lint 0 at 936 and 560 px; tsc, eslint, knip, npm test (1,517 pass) and docs:check
(counts pinned for both examples) clean. Unit test of the group pooling and the hotspot list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…' somatic second hits

A new kind (PMIDs 34635660, 36593350, 28825054, 26000489; Jonsson et al. Nature 2019). Per gene
and cohort (≤4), patient-level and panel-aware:
- carriers: patients with a mutation the study marks GERMLINE (mutation_status, case-insensitive)
  in the gene, among the patients sequenced for it (a tumour mutation panel listing it, or WES).
  A carrier whose panel omits the gene is counted apart (outsidePanel) and a patient not on a
  covering panel is never a non-carrier. The share is of patients sequenced, not germline-tested
  — the warehouse cannot tell a negative germline test from none; the caveat says so;
- second hit among carriers: another called mutation in the gene not marked germline (uncalled and
  off-panel excluded), or a deep deletion (−2) among patients profiled for copy number; reported
  separately and as "either". LOH is not assessed (no allele-specific copy number is read; the
  result names the view's studies that hold FACETS), so the rate is a floor;
- non-carriers: the same somatic rate; Fisher's exact test carriers vs non-carriers per gene, BH
  over the cohort's genes.
Genes: a list (≤60) or every gene with ≥ minCarriers (3) carriers in a cohort (refused past 60,
naming the count). Optional survival: {gene}: OS (OS_MONTHS/OS_STATUS) of that gene's carriers
against non-carriers, KM and log-rank stratified by study. Refuses a cohort with no germline call
at all, and a minCarriers no gene reaches (naming the top genes and their carriers).

SQL (germCarriersSql, germCountsSql, germSurvivalSql) returns counts only — per-patient flags stay
in CTEs; boundary suite covers each; coverage "local". Figure: per cohort one row per gene
(carriers / sequenced, then paired bars of the carriers' second-hit rate and the non-carriers'
somatic rate, q), and the shared KM panel.

Public germline calls are few (lung_nci_2022 100 calls / 72 samples, coadread_tcga_pub 101 /
59, pog570 74 / 68, brca/ov_tcga_pub, prad_su2c_2019 40 / 39). The two with the most calls
spread them over 74–86 genes with 1–3 carriers each and no second hits, so the gallery example is
prad_su2c_2019, the study where the question has an answer: 429 sequenced, BRCA2 17 carriers
(4.0%), 3 with a second somatic BRCA2 mutation (17.6%) vs 7.3% of non-carriers (q 0.34); BRCA1 2
of 6 vs 0.5% (q 0.005); ATM 1 of 7; no deep deletions in carriers. OS: only 128 patients have OS;
BRCA2 carriers 5 (median 12.3 mo) vs 123 (28.1), log-rank p 0.33. Also run on ov_tcga_pub (BRCA1
26 / BRCA2 25 carriers of 316 sequenced, 0 and 1 second hits), brca_tcga_pub, pog570, lung_nci,
coadread_tcga_pub. Figures lint 0 at 936 and 560 px; tsc, eslint, knip, npm test (1,522 pass)
and docs:check (count pinned) clean. Unit test of the per-gene counting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
Master's d7fa8ad made lib/sql-number.ts's finiteSql() the one numeric read of a stored value
("nan"/"inf" are missing, not NaN), with a unit test that fails on any bare toFloat64OrNull in
lib. The preview views' SQL in lib/federation-views.ts, written before it, still read survival
months, the cut-point marker, the MSI score, OS for the KM panels and the progression
probability floor with toFloat64OrNull; all twelve reads now go through finiteSql. No pinned
example count moved (docs:check: every example passes), and npm test is green (1,556).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
…ribute

Most view settings that name an attribute were checked against a pattern without the
`custom:<dataset>/<COLUMN>` form, so an uploaded column could define a cohort but not, say,
stratify a cut-point or supply survival months. lib/custom-data-names.ts gains VIEW_ATTR_RE:
ATTR_NAME_RE's two forms, a built-in name also taking "." and "-" (HLA-A1). The views' shared
ATTR_NAME is now that pattern, and the survival-column checks of survival, cox, cutpoint,
forest, rmst, predictive and hla, the ad hoc custom patterns of cutpoint and adjusted, and
organotropism's SQL-side check use it. Every setting it guards already reaches its query
through clinicalTable(), which merges the uploads in, so no SQL changed.

Checked with uploads on tmb_mskcc_2018 (a patient-level copy of TMB, a lung/other grouping
and OS columns; a sample-level grouping): survival and cox on the custom OS columns give the
same answer as on OS_MONTHS/OS_STATUS (1,661 patients, 832 events; HR 0.9822 both); cutpoint
and forest on them run, as do cutpoint's stratifyBy, forest's strata, rmst's endpoint,
actionability's groupBy, exclusivity's stratifyBy and cancertypes' groupBy on the custom
groupings. The hla, molsubtype, ctdna, organotropism and paired settings pass their names to
clinicalTable the same way but were not run on an upload here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HzVFGjhH59eexZVvbqGWHC
@alisman
alisman force-pushed the session/new-features branch from b5614ce to 27b049b Compare October 23, 2026 13:58
@alisman
alisman marked this pull request as ready for review October 23, 2026 13:58
@alisman
alisman merged commit 7490893 into master Oct 23, 2026
6 of 8 checks passed
@alisman
alisman deleted the session/new-features branch October 23, 2026 13:59
alisman added a commit that referenced this pull request Oct 23, 2026
Snapshots for the 19 view kinds and options #59 added, in four parallel shards

This branch was successfully deployed

1 active deployment
Preview – cohort-lab — 27b049ba Deployed Oct 23, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant