Skip to content

Plots: axes from the study's own profiles, OQL by statement, Not profiled never wild type - #48

Merged
alisman merged 10 commits into
masterfrom
session/plots
Oct 21, 2026
Merged

alisman merged 10 commits into
masterfrom
session/plots

Conversation

@alisman

@alisman alisman commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

The follow-up to #47: plot axes read the study's own molecular profiles, multiple genes as OQL statements, and "Not profiled" handled honestly everywhere.

What changes

Axes from the study's own profiles

  • One Data choice. An axis's Data is a single grouped list: Clinical, then each alteration type the study has (Mutation, Structural variant, Copy number, mRNA, Protein level, …) with the study's own profile names, then OQL query. Picking a profile picks the type, so there is no separate data-source step.
  • The profile decides what the axis is. A profile axis ({"source": "profile", "profile", "gene"}) is read from the profile's own type and datatype in genetic_profile, never from its name:
    • a mutation profile by mutation type, or mutated vs not;
    • a structural-variant profile by present vs not;
    • discrete copy number by its five levels, Deep deletion to Amplification;
    • continuous profiles (mRNA, z-scores, log2 copy number, protein) as numbers.
  • Refusals. A profile a study lacks is refused with the list it does have, and one that pooled studies describe differently is refused. Plots, Cox and logistic all resolve their axes this way.
  • Portal pages. The Results page now picks its mRNA and copy-number profiles by type, no longer by matching profile names.
  • Generic assays (methylation and the others) are listed but disabled until they get an entity picker.

Genes and OQL

  • Multiple genes. An OQL axis's statements may be separated by commas (everywhere OQL is read).
  • One group per line. by: "statement" gives each statement its own group, a pair as "A + B", and three or more as "3 or more lines".
  • Mutation axis with several genes. Applying several genes to a Mutation axis switches it to that form.
  • "Gene set (mutated in any)" is removed from the menu, because OQL asks the same thing.
  • Entry only through the gene dialog. Genes and OQL are entered only through the app's gene query dialog, which checks every entry; the fields no longer accept free text.

Not profiled, never wild type

  • Axes. An OQL axis, and a gene's mutation, copy-number or SV axis, puts a case whose panel omits the gene in Not profiled, never in Unaltered or Not mutated. On msk_chord, KRAS: G12C, RRAGC: MUT has 4,750 samples on panels without RRAGC; they were counted as Unaltered before.
  • Models. Cox and logistic leave not-profiled patients out of the fit and say how many. Before, these patients were silently fitted as wild type, in the reference group. The exclusion is in the row query the method record re-runs.

Point layers and figures

  • Show or hide. A chip for the plotted cases and one for each layer can be clicked to hide or show it, without re-running anything. A case two layers share reappears in the next layer when the layer that drew it is hidden.
  • Point cap 10,000, set from a measurement. On the dev server's headless Chrome, painting costs about 18 ms per thousand points (190 ms at 10,000), and the points travel as about 44 bytes each.

Verified

  • msk_chord, KRAS G12C + RRAGC by line: 1,111 / 61 / 3 both / 19,115 Unaltered / 4,750 Not profiled, each equal to a direct ClickHouse query.
  • TCGA BRCA ERBB2 GISTIC × mRNA z-score: Diploid 480, Shallow deletion 260, Gain 206, Amplification 122.
  • TCGA BRCA TP53 mutation type: includes Not profiled 18, the group the portal also shows.
  • TCGA BRCA Cox (TP53 + ESR1): 1,064 patients, with the 18 not profiled named in the caption.
  • Gallery models: the quoted APC Cox and logistic results are unchanged, and check:methods reproduces every model.
  • Browser: the Data picker, the gene dialog fields, the Mutation-to-OQL switch and the layer toggles were each driven in the browser, with no page errors. New e2e checks cover the dialog-only fields and the toggles.
  • Gate: full gate on e47d599, 271 checks, all pass.

🤖 Generated with Claude Code

https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L

alisman and others added 10 commits October 20, 2026 19:15
Each point layer is now its own group in the SVG, <g data-layer="i">, with the plotted
cohort as data-layer="base". A layer's cases that an earlier layer drew (the overlap
rule) travel as `shared` and are drawn as a second group with display="none". The
figure for MCP and exports looks exactly as before.

On the page, PlotControls shows a "show" zone above the figure: one chip per drawn
layer, in its colour and with its count. Clicking a chip sets data-solo on the figure's
wrapper, and one CSS rule steps every other group back to 8% opacity while revealing
that layer's shared group. So a layer shown alone is complete, e.g. KRAS G12C's 480
samples, not only the 414 it drew itself. Clicking the chip again or pressing Esc
restores every layer. It is a reading gesture kept in browser state like a hover
(invariant 9): it re-runs nothing and does not change the document.

Verified on msk_chord_2024 TP53-mutant samples with [outliers, extremes x 20, KRAS
G12C]: soloing KRAS shows its 414 drawn and 66 shared points at full opacity, the
other groups at 0.08, and Esc restores them. A figure test checks the groups and the
hidden shared group; an e2e check clicks a chip, checks the opacities, presses Esc and
checks the document is untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
The plot renderer drew a scatter and a box at 5k, 10k, 20k and 50k points, and headless
Chrome on the dev server inserted and painted each SVG (median of 3):

  points   paint scatter/box   one hit-test   JSON raw/gzip
  5,000    84 / 117 ms         0.4 ms         220 / 35 KB
  10,000   180 / 190 ms        1.7 ms         440 / 70 KB
  20,000   352 / 337 ms        3.2 ms         880 / 140 KB
  50,000   904 / 752 ms        7 ms           2.2 MB / 350 KB

Painting, not building the string, is the cost: about 18 ms per thousand points, paid
again on every redraw. 10,000 keeps a redraw near 200 ms on this CPU-only machine; 5,000
was inherited from the box plot's first version, not measured. POINTS_CAP moves to
lib/plot-layers.ts (browser-safe) so the sidebar's labels read it, and the DSL reference
and the controls print the constant instead of a typed "5,000". The measurement is
recorded in docs/plot-case-level.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
…iled" never counted as unaltered

An OQL axis's statements may now be separated by commas as well as ";" and new lines,
everywhere OQL is read: a cohort's oql leaf, a plot axis, splits. The engine's parser
and the gene query dialog share OQL_SEPARATOR / oqlStatements in lib/oql-parse.ts; a
comma used to be an error, so nothing that worked changes meaning.

`by: "statement"` gives each statement its own group, named as written with the gene
in capitals. A case matching two statements is the pair ("KRAS: G12C + STK11: MUT"),
three or more is "3 or more lines", and there are at most 6 statements. `by: "any"`, the
default, stays Altered/Unaltered, which Cox and logistic covariates take with
"Unaltered" as reference.

In both, a case that matches no statement is "Unaltered" only when it is profiled for
every statement's gene in the alteration types that statement asks about. Otherwise it
is "Not profiled". Before, the axis counted a sample whose panel omits the gene as
Unaltered, the wild-type error docs/PERFORMANCE.md's denominator rule forbids. The
panel test is the `profiled` clause's own (sample_to_gene_panel_derived, WES counted),
exported as cohort2's profiledCasesSql. EXP/PROT statements read a value, not a panel,
so they need no profiling. Inner columns are named oql_case / profiled_id, because
ClickHouse resolved a bare `id` to another axis's alias once the query was embedded
in the plot's.

The axis control offers "matches OQL (any line)" and "OQL, one group per line". The OQL
gallery example gains a by-line view (STK11, KEAP1, KRAS G12C in lung cancer). The DSL
reference and docs/plot-case-level.md say all of this.

Verified on msk_chord_2024, all 25,040 samples, `KRAS: G12C, RRAGC: MUT` (RRAGC is on
the panels of 20,048 samples):
- by line: KRAS G12C 1,111, RRAGC 61, both 3, Unaltered 19,115, Not profiled 4,750
- any: Altered 1,175
Each equals a direct ClickHouse query over the panel and event tables. The OQL
example's stated numbers (1,535 Altered, 6,274 Unaltered) still hold. docs:check runs
every example and check:methods reproduces every view's numbers, Cox and logistic with
OQL covariates included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
Every axis field that takes genes or OQL gets a button beside it that opens
GeneQueryDialog, the app's one way of entering genes, with search over genes, aliases and
gene sets, a check of every entry as it is typed, alias fixes, and recent and saved lists.
That covers an alteration axis's OQL query or gene(s), a molecular axis's gene, and the
colour/ring pickers, which share AxisPicker. The fields stay editable as text.
- OQL opens one line per statement and applies them comma-separated. The limit is 6
  statements when grouping by line, 30 otherwise, and the description says how lines are
  read for the chosen grouping.
- A gene set opens in gene-list mode, capped at ALTERATION_GENE_SET_CAP (20). A
  molecular axis takes exactly one gene.
ALTERATION_GENE_SET_CAP moves to the browser-safe lib/plot-oql.ts so the dialog can
enforce it. The dialog's help says commas separate entries too.

Verified in the browser: the OQL dialog opens with "KRAS: G12C\nRRAGC: MUT", and adding
a line applies "KRAS: G12C, RRAGC: MUT, STK11: MUT" with by: "statement" kept; the gene
dialog turns a TP53 axis into the set [TP53, KRAS]; no page errors. A new e2e check
opens, edits and applies the OQL dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
… axis is follows the profile

An axis is now a clinical attribute, one of the study's own molecular profiles, or a query
across genes. A profile axis, {source: "profile", profile, gene}, is read as the study
describes the profile in genetic_profile (alteration type and datatype), never from its
name:
- a mutation profile (MAF) by mutation type, or `by: "mutated"`;
- a structural-variant profile by whether the case has one;
- a discrete copy-number profile by its own calls (Deep deletion, Shallow deletion,
  Diploid, Gain, Amplification, drawn in that order), not only the ±2 events;
- a continuous one (mRNA, z-scores, log2 copy number, protein) as a number.
lib/profile-catalog.ts (browser-safe) holds that reading, and lib/plot-profile.ts resolves
a profile axis against every study a view draws over into the forms the engine already
runs (molecular with `discrete`, alteration with `by`). A profile a study lacks is refused
with the list it has, and one two pooled studies describe differently is refused.
Generic-assay profiles are offered but disabled until they get an entity picker. Plots,
Cox and logistic all resolve their axes this way.

Panel-aware single-gene axes: a case with no mutation, high-level copy-number call or SV
in the gene is "Not mutated" / "Neutral" / "No structural variant" only when it is
profiled for that gene in that alteration type; otherwise it is "Not profiled", as the
OQL axis already is. In TCGA BRCA, 18 samples outside the mutation profile were counted as
not mutated before.

The page: studyProfiles() also returns each study's catalog; Explore returns it
(profileCatalog), and the lab and portal catalogs carry it. AxisPicker's Data is one
grouped list: Clinical, then each alteration type with the study's own profile names under
it, then Across genes (OQL, gene set). The fields that follow come from the choice:
attribute; gene (with the gene query dialog) plus "Plot mutations by" for a mutation
profile; or the query. The Results page picks its expression and discrete copy-number
profiles by type and datatype, no longer by matching profile names, and its Plots tab uses
profile axes. The co-expression pair plot uses profile axes too. The DSL reference
documents the profile axis, and a gallery example shows it.

Verified on brca_tcga_pan_can_atlas_2018:
- ERBB2 GISTIC x mRNA z-scores: Diploid 480, Shallow deletion 260, Gain 206,
  Amplification 122.
- TP53 mutation type: Missense 209, Truncating 102, Splice 24, Inframe 5, Multiple 4,
  Not mutated 706, Not profiled 18.
- ERBB2 SV: 15 / 1,037 / 18 not profiled.
- msk_chord's `cna` profile shows its five levels, plus one sample whose stored value is
  -1.5, shown as written.
- The quoted APC Cox and logistic results in the gallery are unchanged.
In the browser, picking Mutations in the Data list rewrites the axis and redraws, and the
Cox covariates use the same picker; no page errors. Unit tests cover the reading,
resolution, refusals and SQL; the new example's cohort is pinned.

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

The gene-set entry gave two groups, mutated in any of the genes or not. "OQL query",
grouped by any line, asks the same with `GENE: MUT` per gene, and can say more (any
alteration, amplification, a protein change), so the two entries read as one choice under
two names. "Across genes" now offers only the OQL query.

The engine still accepts the `genes` form, so documents and the gene-set Cox example work
unchanged. The picker shows such an axis as its OQL equivalent ("TP53: MUT, KRAS: MUT",
any line), and the first edit makes it an OQL axis. GeneSetInput, used only there, is
removed.

Verified in the browser: an axis of genes [TP53, KRAS] shows Data "OQL query" with
"TP53: MUT, KRAS: MUT"; "Across genes" lists only OQL query; switching to one group per
line writes {oql: "TP53: MUT, KRAS: MUT", by: "statement"}.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
…t's groups read as groups

The Gene and OQL query fields of an axis are no longer text boxes. Each shows its value
and opens the app's gene query dialog when clicked or typed into, so a half-typed or
misspelt symbol never reaches the document; every entry is checked in the dialog first,
with alias fixes offered. The fields do not open on keyboard focus alone: the dialog
hands focus back on closing, and tabbing past would open it. The separate checklist
button goes, since the field is now the trigger. GeneInput and OqlInput, used only
there, are removed.

The Data list's headers are a tinted band in small bold text, with a rule between groups
and the profiles indented under them, so a header no longer reads as one more choice.

Verified in the browser:
- the axis block holds no text input; clicking Gene opens the dialog with TP53, and
  applying KRAS sets the axis;
- focusing the field opens nothing, and typing a letter opens the dialog;
- the menu's headers render as bands over indented items;
- no page errors.
The e2e OQL check now also asserts the axis has no free-text field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
… with a group per gene

A mutation profile axis reads one gene, because its groups (mutation types, not mutated,
not profiled) describe one gene's mutations. Applying more than one gene in its gene
dialog no longer stops at the one-gene limit: the axis becomes the OQL query
"GENE: MUT, …" grouped one group per line, which is what several genes' mutations can
honestly be (each gene, each pair, three or more, unaltered, not profiled). The dialog's
description says so, and the axis then shows as Data "OQL query". Other profile axes
still take one gene.

Verified in the browser on brca_tcga_pan_can_atlas_2018: applying TP53 and PIK3CA to a
TP53 mutation axis wrote {oql: "TP53: MUT, PIK3CA: MUT", by: "statement"} and drew
Unaltered 443, PIK3CA 263, TP53 263, both 81, Not profiled 18.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
…ne, and say how many

A gene's alteration axis (and a discrete copy-number profile) answers "Not profiled" for a
case whose panel omits the gene. A plot shows that as its own group. A model has no use
for it as a level: an unknown status is neither wild type nor a hazard ratio worth
reporting. Before panel-aware axes, those patients were silently fitted as wild type, in
the reference group.

coxRows and logisticRows now drop "Not profiled" values of the outcome and of each
undecidable covariate in the row query itself, the query the method record's script
rebuilds and re-fits, so the script reproduces exactly the rows the view fitted. A
second query (coxNotProfiled:<endpoint>, logisticNotProfiled, labels the method data
does not read as rows) counts those patients. The caption says how many were left out,
separately from those missing the endpoint or a value, and each study's fetch step in
the method record says how many of its missing rows were not profiled.

Verified on brca_tcga_pan_can_atlas_2018:
- Cox OS ~ TP53 mutated + ESR1 z-score: 1,064 patients, TP53 HR 1.07, no "Not profiled"
  term; the caption names 18 not profiled and 2 without the endpoint.
- Logistic PIK3CA mutated ~ TP53 mutated: 1,066 patients, OR 0.52, 18 named.
check:methods: every gallery Cox and logistic script reproduces its view, unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BhP3GAdWXr2EtfBDHvRS1L
…ta list's groups as bold text

The row above the figure was "show one layer alone", which with a single layer could
only fade the plotted cohort, never the layer's own points. It is now one toggle chip
per drawn thing: the plotted cases ("all samples (13,087)") and each layer. Any of them
can be hidden, alone or together, and Esc shows everything. It stays a reading gesture
kept in browser state (invariant 9), re-running nothing.

A case two layers share is drawn by the first. Its copy in a later layer is now tagged
with the layer that drew it: the runner records sharedOwners beside `shared`, and the
renderer writes one hidden group per owner, <g data-layer="k" data-shared
data-owner="j">. So hiding layer j brings its shared cases back in layer k's colour,
rather than hiding them from both. One CSS rule per key fades a hidden group (0.08) and
shows the copies a hidden owner leaves.

The Data list's group headers are plain bold text, a little space between groups, the
profiles indented beneath, with no band or rule.

Verified in the browser on msk_chord TP53-mutant samples with [outliers, trend]:
- hiding outliers fades its group and shows trend's copies of the cases outliers drew;
- hiding "all samples" fades the density;
- Esc restores every group; the document is unchanged; no page errors.
The figure test checks the owner-tagged shared groups; the e2e check is rewritten for
the toggles.

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

vercel Bot commented Oct 21, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
cohort-lab Ready Ready Preview Oct 21, 2026 3:00am UTC

Request Review

@alisman
alisman merged commit b08d146 into master Oct 21, 2026
5 checks passed
@alisman
alisman deleted the session/plots branch October 21, 2026 03:16

This branch was successfully deployed

1 active deployment
Preview — e47d5996 Deployed Oct 21, 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