Skip to content

Cohorts from long id lists, locked cohorts, and saving cohorts by name - #45

Merged
alisman merged 7 commits into
masterfrom
session/group
Oct 19, 2026
Merged

alisman merged 7 commits into
masterfrom
session/group

Conversation

@alisman

@alisman alisman commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

Groups defined as long lists of ids, cohorts and analyses locked against data drift, and a tidier save flow.

What changes for a researcher

  • Cohorts from a list of ids, any size. Paste sample or patient ids in three places: a view's "+ Add cohort", the "Cohorts in this analysis" panel ("From a list of ids…", no view needed), and the Saved tab ("New cohort from a list of ids…", study chosen, saved straight to the library). Ids are matched to the study; what matched nothing is named.
  • Locking. "lock to these patients" on a cohort, or "Lock all cohorts" for the analysis: the cohort and its split arms are frozen to their patients at one moment, with the data and OncoKB versions. The card then says how far the filter has drifted since (now selects / entered / left / new samples). Unlocking returns it to its filter.
  • Saving a cohort asks for its library name, and the cohort takes that name in the analysis. "save changes to library…" appears only for a cohort linked to a saved one. A saved cohort always names its study.
  • Example cohorts move to a Cohorts tab on the Examples page and appear nowhere else.

How

  • Lists are kept server-side as stored id sets (id_set, lib/id-set-store.ts, owned and shared by lib/access.ts, immutable members, access log) and named by id in the document, so documents and ?doc= links stay small. Each statement that reads one carries it as a ClickHouse external table instead of inlined SQL, removing the max_query_size wall (inline sample lists failed at ~13k ids; a stored 60,000-sample cross-study set runs in 7.3 s). Inline lists stay valid DSL (MCP, scripts, signed-out readers) up to INLINE_ID_SQL_LIMIT, refused by name past it.
  • A lock is one id_set row (kind = lock): the cohort's patients, plus their samples and each arm's patients as parts. The engine evaluates the set and still runs the filter beside it for the drift readout (lib/cohort-lock.ts, POST /api/lock).
  • Patient-data boundary: sets go only to the patient-level connection holding each study (carriesPatientIds counts sets and locks); answers that read a set bypass the shared cache. See docs/DATA-BOUNDARY.md.
  • DSL 2.6 → 2.8 (no-op upgrades), so an older engine refuses documents naming sets or locks. Docs regenerated.

Database

Migrations 0006_id_sets.sql and 0007_id_set_parts.sql (additive). Both are already applied to the shared Supabase that cbiolab.vercel.app uses. Any other deployment with its own Postgres needs npm run db:migrate before this code.

Verification

GATE_PORT=4111 scripts/gate.sh passes on this branch rebased onto current master: lint, build, 1,316 unit, 47 Postgres, 6/6 planted access bugs, 465/465 portal comparisons, cohort logic, method scripts, Python conformance, 266 e2e checks (new checks for pasted lists, list storage on save, locking, the save dialog, study pinning, and example cohorts). Exercised by hand on a test server against the live warehouse and Supabase.

Preview

No deploy preview: this repo deploys to Vercel from master.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti

alisman and others added 7 commits October 19, 2026 17:47
A sampleList/patientList clause may now name a stored id set
({"kind":"sampleList","set":"<id>"}) instead of carrying its ids. The list
is saved once (POST /api/id-sets, or "+ Add cohort" -> "From a list of
ids"), matched to the studies' own samples or patients first (what matched
nothing is reported), and the document, its ?doc= link and every request
carry only the set's id.

Why: an inline list is written into the SQL. On msk_chord_2024 sample ids
hit ClickHouse's 256 KB max_query_size between 12,000 and 13,000 ids, each
view re-inlined the list, and the readback echoed it: at 10,000 ids every id
appeared four times in an 839 KB response. The ?doc= URL broke first, at
roughly 350 sample ids (Node refuses headers past 16 KB).

How:
- db/migrations/0006_id_sets.sql: id_set (owner, sharing columns as for
  custom datasets, members gzipped per study, immutable by trigger, soft
  delete) and id_set_access_log. lib/id-set-store.ts is the only module that
  touches them; access goes through lib/access.ts. Group deletion unshares
  sets too.
- lib/id-sets.ts: the per-request context. The compiler names one ClickHouse
  external table per set and study (IN (SELECT id FROM _idset_...)), and
  lib/ch.ts uploads it only with a statement that names it, only to the
  patient-level connection holding that study.
- lib/id-sets-server.ts: every engine route passes its body as `uses` to
  withRequestCustomData, which reads the sets it names as the signed-in
  principal. MCP, scripts and signed-out requests do not read sets, and such
  a clause is refused by name. carriesPatientIds counts a set as ids.
  Answers that read a set bypass the shared result cache (hasIdSets()).
- Inline lists are refused by name past INLINE_ID_SQL_LIMIT (200 KB of ids
  per study), pointing at stored sets, instead of ClickHouse's syntax error.
- Sharing an analysis or cohort wider than a set it names warns, as for
  custom datasets, and offers to share the reader's own sets the same way.
- DSL 2.7 (no-op upgrade step) so a 2.6 engine refuses a document naming a
  set rather than reading it as an empty list. Docs regenerated;
  DATA-BOUNDARY.md and PERFORMANCE.md updated.

Verified against the live warehouse with runAnalysis (compare + clinical):
all 25,040 msk_chord_2024 samples in 4.9 s, 416 B document, 23 KB response
(inline: fails); 60,000 samples across msk_chord_2024 + genie_public in
7.3 s, 504 B document, 45 KB response. Saving the 60k set took 0.9 s.
Tests: test/id-sets.test.ts (compile, request context, boundary, inline
limit); idSetContract on PGlite and Postgres (round trip, access, immutable
members, delete and unshare take effect at once: both mutation-checked);
e2e paste -> save -> add, with the set refused signed out, for another user
and after deletion. Gate (GATE_PORT=4111 scripts/gate.sh) passes.

Deploying needs `npm run db:migrate` for 0006 on the configured Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
Where the reader is signed in, the page no longer puts inline id lists into
documents it saves or builds:

- app/lab/id-sets-client.ts: storeCohortLists / storeDocLists replace every
  inline sampleList/patientList (its "<study>:<id>" ids, the only ones an
  inline list ever matched) with a stored set, {kind, set, label, n}. A list
  that matches no case, or names only studies where patient ids may not go,
  stays inline: it selects nobody there either way.
- Lists entering the document: an oncoprint drag-selection and a Cell
  Explorer import become cohorts naming a set (the paste tab already did).
  Signed out, they stay inline as before.
- Saves: the page's persist / persistSpaces / saveShared wrap every save path
  (save to library, save analysis, save over a shared analysis, the
  assistant's save, Explore's save as cohort) so what is saved names sets,
  and the open document is given the same sets, so a re-save stores nothing
  new. saveCohortToLibrary takes the saved definition into the copy, so the
  cohort then has nothing left to save.
- db/migrations/0001's comment no longer says identifiers never live in
  Postgres: definitions are rules, and a list a definition names is a stored
  id set. DSL reference and CLAUDE.md say the same.

Inline lists remain valid DSL for MCP clients, scripts and signed-out
readers. Analyses saved earlier with inline lists convert on their next save.

Verified: new e2e check (save a cohort carrying an inline list -> the saved
definition and the analysis's copy name one new set, nothing left to save,
same count); the oncoprint drag check now asserts the stored set's size
equals the selection and the compiler's count (3,556 = 3,556 = 3,556).
Gate (GATE_PORT=4111 scripts/gate.sh) passes: 255 e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
…ts it selects now

A cohort can be locked so it stops drifting as patients are added to the
data. Its `lock` names stored id sets of its patients and their samples then,
the moment, the data version and the OncoKB release, and the frozen patients
of each of its split arms (`children`, by "<split> / <arm>"). The document's
`locked` marks an analysis whose cohorts were all locked at one moment.

Engine:
- lib/cohort-lock.ts applyLocks runs after splits expand and before
  `extends` resolves (runAnalysis and resolveDocCohorts): a locked cohort or
  arm becomes its set alone; an arm added after the lock still extends its
  frozen cohort, so arms add up to it. compileCohortDef honours `lock` too, so
  Explore, oncoprint specs and links cannot fall back to the live definition.
- The definition is still run beside the set: result.cohorts[name].lock
  reports what it selects now, who it would add or drop, frozen patients no
  longer in the data, and new samples of the frozen patients
  (PatientLevelResolver.lockDrift, one logged query per study). The readback
  says "locked <date>: N patients who were <definition>".
- lib/cohort-lock-server.ts + POST /api/lock ({doc, cohorts?}) evaluate each
  cohort and arm at one moment and save the sets (empty sets allowed: an arm
  may hold nobody). Patient-level studies only; needs a sign-in.
- idSetRefsIn reads a lock's sets for its request; carriesPatientIds counts a
  locked cohort as ids; a request may name up to 128 sets.
- DSL 2.8 (no-op upgrade) so a 2.7 engine refuses a locked document instead
  of silently running the live definitions. Docs regenerated.

Page: a cohort's menu has "lock to these patients" / "unlock"; "Lock all
cohorts" beside the cohorts heading locks every cohort and arm at once
(then "locked <date> · unlock all"); unlocking one cohort clears the
analysis lock. A locked card says when, and either "unchanged since" or how
the data has moved. "Update to the saved version" asks before it drops a
lock; rename and notes leave it alone.

A lock freezes patients, not samples (new samples of frozen patients are
counted and shown), and not the values recorded about them.

Verified on msk_chord_2024: locking KRAS NSCLC (2,194, with a split) and EGFR
(2,158) took 0.45 s and left counts and views unchanged; with the definition
widened under the lock the cohort still answered 2,194 and reported
now 7,115, +4,921 entered, 0 left; an arm added after locking summed to
2,194 (conserved). test/cohort-lock.test.ts (10 checks); new e2e check (lock
all -> same counts, "unchanged since", one moment for all; unlock one ->
analysis lock cleared). Gate (GATE_PORT=4111 scripts/gate.sh) passes: 256
e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
- "save to library…" / "save changes to library…" open a dialog prefilled
  with the cohort's library name, which says what saving does: a new saved
  cohort, the one it is linked to updated, or (in red, button "Replace")
  another saved cohort of that name replaced.
- Saved under another name, the cohort takes that name in the analysis too,
  views drawing it included (as "edit…" renames), unless the analysis already
  has a cohort of that name; then it keeps its name and a message says so.
- "save changes to library…" is offered only for a cohort linked to a saved
  cohort (copied from it, or saved before) and changed since. A cohort that
  merely shares a saved cohort's name (default names repeat: "selection",
  "from Cell Explorer") is offered as a new save, and the dialog warns.
- The pasted-list tab has no default name: "Match and save" waits for one.
- The source badge reads "in library" (was a bare hash), and its tooltip and
  panel say "linked to your saved cohort" (was "copied from", wrong for a
  cohort that was saved rather than copied).

Verified on the test server (dialog, rename, no page errors) and in the
e2e save check: rename on first save, view follows, "in library" badge,
"Updates your saved cohort" on re-save, a same-name cohort offered as new
with the Replace warning. Gate (GATE_PORT=4111 scripts/gate.sh) passes: 256
e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
A cohort with no `study`/`studies` runs in its analysis's study. Saved to
the library like that, it would silently take the study of whatever analysis
it joined next ("EP300 mutant in MSK-CHORD" becoming "EP300 mutant in
GENIE"). The page's persist, which every cohort save goes through (a cohort's
card, Explore, a library example, the assistant's save), now pins such a
cohort to the study it was running in: the analysis's, or for a template its
parameter's current value. The analysis's copy takes the same definition, so
it stays "in library", not "changed". Saved analyses are untouched, so
templates (`"study": "$study"`) keep working.

Verified: the e2e save check asserts a cohort with no study is saved naming
msk_chord_2024, the analysis's study, and the analysis's copy names it too.
Gate (GATE_PORT=4111 scripts/gate.sh) passes: 256 e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
…xample cohorts move to Examples

- The paste-ids form is its own component (app/lab/list-cohort.tsx: ListSource,
  ListCohortDialog), used in three places:
  - the Saved tab: "New cohort from a list of ids…" chooses the study (there
    is no analysis to take one from) and saves straight to the library; a name
    already taken gets " (2)", never an overwrite;
  - the analysis's "Cohorts in this analysis" panel: "From a list of ids…"
    adds the cohort before any view exists (the panel now shows with no
    cohorts, saying how to add one);
  - a view's "+ Add cohort", as before.
  A list cohort always names the study it was matched in.
- Example cohorts are only on the Examples page, in a new Cohorts tab (preview,
  analyze, save to library); they are gone from the Saved tab and from
  "+ Add cohort"'s library.
- User-facing text speaks of list cohorts, not stored id sets.

Verified: new e2e check (library: study must be chosen, saved with its set
and study; panel: cohort added with no view; examples only on the Examples
page). e2e-saved.cjs follows the examples to the Examples page. Gate
(GATE_PORT=4111 scripts/gate.sh) passes: 257 e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
db/migrations/0007 adds id_set.kind ('list' | 'lock'), parts (sizes) and
parts_gz (members), covered by the immutability trigger. A lock keeps the
cohort's patients in ids_gz as before, and beside them, as named parts, those
patients' samples ("samples") and each split arm's patients
("arm:<split> / <arm>"). The cohort's `lock` names that one set; an arm names
its part (`part`), and compiles to a ClickHouse external table of its own,
uploaded only where the set's ids may go. Locks made before parts (separate
sample and arm sets) are still read as they were (lockSamples, applyLocks).

Verified against the live warehouse: locking KRAS NSCLC (with a split) and
EGFR made exactly one id_set row each (KRAS: 2,194 patients, parts samples
2,225 and three arms); counts, views, drift (+4,921 when widened) and an arm
added after locking (conserved) unchanged. Unit tests for parts (table names,
uploads, drift against the samples part, older locks); store contract for a
lock's parts and their immutability; the e2e lock check asserts one row per
locked cohort. 0007 applied to the shared Supabase (additive; production
unaffected). Gate (GATE_PORT=4111 scripts/gate.sh) passes: 257 e2e checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NvdfHurd9t63GK2ToazMti
@alisman
alisman merged commit 01197b4 into master Oct 19, 2026
1 of 2 checks passed
@vercel

vercel Bot commented Oct 19, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated
cohort-lab Building Building Preview Oct 19, 2026 6:15pm UTC

Request Review

This branch was successfully deployed

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