oncodsl / CLAUDE.md
govindbalki's picture
Upload folder using huggingface_hub
0d64fd9 verified
|
Raw
History Blame Contribute Delete
134 kB
# OncoDSL β€” context for Claude
Long-term goal: a genetic-programming engine that, over a small DSL of data
operations, **rediscovers the MSI / mismatch-repair gene signature from TCGA
colorectal data blind**. The engine must not see real gene names β€” an "airgap"
layer between the data layer and the engine hides them.
## Chunk status
Built in chunks. Current state:
- **Chunk 1 (done):** project scaffold + cBioPortal data load + Streamlit
sanity viewer. See `README.md`.
- **Chunk 2 (done):** DSL operators (`Load`, `Select`, `Reduce`, `Split`,
`Associate`, `Effect`, `Search`, `Fit`, `Apply`) live in `dsl/`. Airgap
(`anonymise` / `reveal` + sealed map at `data/processed/_sealed_gene_map.json`)
lives in `airgap/`. H1 verification (known-answer MMR + immune check on the
NAMED matrix) lives in `validate/h1.py`. Viewer is split into two top-level
tabs β€” **Dataset** (the five sanity panels) and **Hypothesis 1** (intro,
DSL reference with nouns + verbs, graphviz composition diagram with
`Given:` input nodes, then verification outputs in this order: dynamic
pass/fail Conclusion β†’ score distributions β†’ Effect β†’ Fit β†’ collapsed
"How to read this", plus an airgap demo). Glossary expander sits above
the tabs.
- **Chunk 3 (done):** GP engine in `engine/` β€” strict airgap; composes
Select/Reduce/Fit programs over the anonymised matrix with prefilter +
tournament-selection GP + permutation null. Orchestrator at
`scripts/run_h2.py` (Load β†’ anonymise β†’ engine β†’ persist artefacts).
Artefacts at `data/processed/h2/{evolution_log,result}.json`, both
anonymised. FastAPI surface in `api/app.py` (`/health`, `/run`,
`/result`, `/reveal`) β€” `/reveal` is the only endpoint allowed to read
the sealed map and translates only the supplied IDs. Viewer gains a
"Hypothesis 2" tab (5 cards: hypothesis β†’ setup β†’ evolve replay β†’
result β†’ reveal + computed conclusion) that fetches from the API.
Validation lives in `validate/h2.py` (reveal + MMR overlap).
- **Chunk 4 (done β€” Stage 1):** React/Next.js "Lab" at `web/`
(App Router + TS + Tailwind + Recharts). Adds a second selectable
engine objective β€” `CorrelationObjective(direction)` for continuous
targets like TMB β€” alongside `BinaryAUROCObjective` for MSI. New API:
`POST /runs` (worker thread + SSE bridge), `GET /runs/{id}`,
`GET /runs/{id}/stream` (`sse-starlette`), `GET /runs/{id}/result`,
`POST /evaluate` (reveal supplied IDs + score overlap with `"MMR"` or
`"immune"`). Lab page is one Client Component with 5 sections:
objective builder (survival / unsupervised disabled, Stage 2) β†’
parameter inputs β†’ Run β†’ live fitness curve + population grid
(survivors solid accent, discarded faded) β†’ result + reveal &
evaluate. Reuses the same engine β€” `scripts/run_h2.py`, the legacy
`/run /result /reveal /health` endpoints, and the Streamlit viewer
are untouched.
- **Chunk 4 follow-ups (done):**
- **Prefilter optional + bigger GP budget.** Engine
`prefilter_n: int | None`; `None` means the GP samples its
initial-population AND its mutation operator from the full opaque
column set (~17k after NaN drop), not from a univariate shortlist.
`_prepare` returns `gp_pool` + `baseline_genes` separately β€” the
baseline keeps using the univariate top-K so it stays a fair sanity
check. API accepts `prefilter_n: null`. Lab UI defaults to OFF (60
gens / 300 pop) with an "Off (all genes) / On" toggle; turning On
flips back to 30 / 150 / `prefilter_n=2000`. `scripts/run_h2.py`
untouched (explicit `--prefilter-n 2000`).
- **InfoTips everywhere.** Single `<InfoTip>` Client Component drives
every "?" in the Lab (objective cards, parameters, run / live /
result / evaluator labels + sub-labels). Verbatim tooltip copy
lives in the `OBJECTIVE_TIPS` / `PARAM_TIPS` / `TIPS` records at
the top of `Lab.tsx` β€” never inline.
- **Program graph (React Flow).** `web/app/ProgramGraph.tsx` reads
`feature_sets` straight from the winner / candidate payload (no
backend change) and renders the DSL program as a node graph:
Tier-1 lanes [Expression matrix β†’ Select(opaque IDs) β†’ Reduce
(mean) β†’ Score pill] per gene-set, then [Score(s) β†’ Fit β†’ output
node]. Tier-1 = light-teal group box, Tier-2 = dashed `#BC6B2E`
wrapper (omitted for single-set programs). Custom node types only,
palette tokens only; React Flow chrome stripped via
`ProgramGraph.css` (`opacity:0` handles, no Background, no
MiniMap, no Controls, attribution hidden, scroll passes through).
The graph card defaults to the winning program; clicking any
candidate in the population grid swaps the graph to that candidate
in place. After the Evaluator runs, Select-node pills show real
symbols and matched reference genes are filled in the project
highlight (`#BC6B2E`).
- **Chunk 5 (done β€” engine_v2 + full-population UI):**
- **engine_v2/** β€” strongly-typed program synthesis. Programs are
trees over a five-type grammar (`Matrix`, `Vector`, `FeatureSet`,
`Agg`, `Op`). Nodes: `MatrixTerminal`, `Select(Matrix,FeatureSet)
β†’Matrix`, `Reduce(Matrix,Agg)β†’Vector`, `Combine(Vector,Vector,Op)
β†’Vector`. `Reduce.agg ∈ {mean, median, max, min, var}`,
`Combine.op ∈ {add, sub, mul, protected_div, mean}`. Output type =
`Vector`. Split / Effect deferred to v2.1. Ramped half-and-half
init; tournament + elitism; subtree crossover with strict type
matching; subtree + point mutation; depth/node budgets. Termination
guarantee: depth-floor closes Matrix to `MatrixTerminal` and Vector
to `Reduce(MatrixTerminal, agg)`, so no tree ever has an open slot.
Reduce.agg vocab also widened in `dsl.Reduce` itself.
- **Two v2 objectives, with finite worst-case fallbacks**:
`BinaryAUROCObjective` (MSI/MSS β€” orientation-agnostic
`max(AUROC, 1βˆ’AUROC)`, worst = 0.5) and the v2 `CorrelationObjective`
(TMB β€” `βˆ’spearman(score, TMB)`, worst = 0.0). Degenerate programs
(empty FeatureSet, constant/NaN output) get the worst score; they
never leave the API as NaN/Infinity.
- **Permutation null is winner-fixed in v2:** hold the winning
program fixed and shuffle the target N times; p = fraction of
nulls β‰₯ observed. The old "rerun the univariate baseline on
shuffled labels" null does NOT transfer to v2 and was dropped.
- **API**: `POST /runs` accepts `engine: "v1" | "v2"`. v2 worker uses
`engine_v2.run_v2_pipeline_streaming`. Full population persisted
per generation in `run.log[i].candidates` (both engines); SSE
stream keeps the bandwidth budget light by trimming to top-12.
`GET /runs/{id}` exposes `generations_persisted`; full per-gen
populations come from `GET /runs/{id}/population/{generation}`
(404 for unpersisted indices, never 500). All endpoint returns,
the in-memory `run.result`, and the SSE payloads pass through
`_json_finite()` β€” NaN / ±∞ β†’ `None`, so `JSON.parse` never breaks.
- **Frontend (web/)**: Lab now posts `engine: "v2"`. Replaced the
old top-12 grid with `PopulationTiles` (full per-gen population,
fitness-tinted teal ramp, "NnΒ·dD" structure signature, generation
stepper bounded to `generations_persisted`). New
`lib/programRepr.ts` is the SHARED parser for the typed
`program_repr` (also tolerates the legacy v1 `Fit(…)` shape).
`ProgramGraph.tsx` was rewritten to consume the parsed tree and
now renders arbitrary Select/Reduce/Combine/M shapes; same custom
nodes, same `ProgramGraph.css` chrome strip. `PasteToDraw.tsx`
feeds arbitrary `program_repr` text into the same renderer.
`ParameterFlow.tsx` adds a collapsed "How these parameters
relate" disclosure under the Parameters card caption β€” a centered
single-column HTML/flex diagram (max-width 460px, width 100%,
never overflows): step boxes, colour-grouped space/effort/
validation pills that wrap on narrow widths, plain text `↓`
arrows, amber final "permutation p" box. Generations pill uses a
`↻ ` text prefix (not an icon glyph). A single
`lib/fmt.ts::fmtFit()` formats every fitness/score across tiles,
cards, tooltips, the result panel, and the live curve β€” falling
back to "β€”" if a non-finite value ever reaches the UI; tiles also
use `fitnessForOrder()` so any non-finite sinks to the bottom of
the grid with the lightest tint.
- **Chunk 5 follow-ups (done β€” UX polish, no GP / API changes):**
- **Rich Parameter help.** New `<ParamHelp paramKey>` Client
Component replaces the small InfoTip on the 8 Parameters labels.
Hover/focus still shows the SHORT one-liner; click opens a modal
dialog with text + diagrams. `ParamHelpProvider` mounts a single
portal-modal at the Lab root so any trigger can open it (the
ParameterFlow chart's pill names are also ParamHelp triggers).
Registry of detailed content lives in `web/app/paramHelpContent.tsx`;
six diagrams (Generations loop, MaxSets, Lambda trade-off, Seed
dice, Permutations histogram, Prefilter funnel) live under
`web/app/diagrams/`. Modal is role="dialog" aria-modal="true" with
Esc / X / backdrop close, focus trap, and focus return.
- **Program graph + paste-to-draw.** `ProgramGraph.tsx` now adds
Tier-1 (light teal `#F1F6F7` / `#3A6B7E`) and Tier-2 (dashed
`#BC6B2E`) wrapper nodes when the parsed root is `Combine` (single-
score programs skip wrappers). `PasteToDraw.tsx` is now controlled
(text lifted to Lab so tile clicks AND the winner-default both
write into it), placeholder fixed to a well-formed example, hint
spells out the Reveal restriction for pasted text, and a collapsed
"Format &amp; examples" disclosure documents the grammar (`M`,
`Select`, `Reduce` aggs, `Combine` ops, rules, annotated example).
- **Copy & load.** New `web/app/CopyButton.tsx` (`navigator.clipboard`
with `execCommand("copy")` fallback) drives a "Copy program" button
next to the winner in the Result panel and an icon-only copy
overlay on every tile in `PopulationTiles`. Tile body uses
`role="button" tabIndex={0}` so the inner icon-button can stop
propagation; clicking the body loads the program into the textarea
AND the graph.
- **Chunk 6 (done β€” full DSL in engine_v2 + objective explainers):**
- **Full DSL grammar in engine_v2.** Typed grammar grew from
{Matrix, Vector} to {Matrix, Vector, Scalar, Model}. New nodes:
`Split(Vector, Predicate)β†’Vector` (predicates: `score` = above /
below median, `stage_late` = AJCC III/IV; one level only,
mean-centre per branch, min subgroup guard);
`Associate(Vector, target, kind)β†’Scalar` and
`Effect(Vector, target, kind)β†’Scalar` (Effect residualises on
stage + age β€” observational backdoor adjustment, "only as good as
the measured confounders"); `FitApply(Vector, target)β†’Vector`
(Fit a LR/OLS on the score, Apply in place); `Search(Matrix, k)
β†’Matrix` (bounded univariate nested ranker, capped at k ≀ 4 and
200 cols, **gated OFF by default** per A4 β€” visible in the
grammar but rate 0 until cost is acceptable).
- **ExecContext** threads through every `Node.execute(ctx)` β€”
bundles `M` (opaque), `clinical` (named: stage, age only), and
`labels` (named: msi, tmb only). The pipeline and worker plumb
clinical + the OTHER target through so a v2 MSI run can still
Associate / Effect / FitApply against TMB and vice versa.
- **Fitness handles every output type.** Vector β†’ objective metric
on per-patient scores; Scalar (Associate / Effect) IS the
fitness, oriented per objective (|Β·| for MSI, `-x` for TMB);
Model β†’ routed through FitApply which auto-Applies and emits a
Vector. Worst-case floor still applies (0.5 MSI, 0.0 TMB).
- **Typed GP** extended with type-safe init / crossover / mutation
for every new node; ramped half-and-half now spawns ~20%
Scalar-rooted programs so Associate / Effect appear in the
population; rates dict configures Effect / Split / FitApply /
Search injection. Termination guarantee: Matrix β†’ MatrixTerminal,
Vector β†’ Reduce(MatrixTerminal, agg), Scalar β†’
Associate(Reduce(MatrixTerminal, "mean"), target, "spearman").
- **Parser + renderer + format guide.** `web/lib/programRepr.ts`
parses every new operator (spaces tolerated). `ProgramGraph.tsx`
renders Split / Search / FitApply / Associate / Effect as
accent-bordered verb nodes (same theme). PasteToDraw's "Format &
examples" disclosure documents the full grammar with one short
annotated example per operator.
- **Objective modals.** The four objective `?` icons (MSI / TMB /
Survival / Unsupervised) now use the same rich ParamHelp modal
infrastructure as the parameters β€” verbatim SHORT + DETAILED
text per the prompt, shared intro about "an objective is the
rule that scores every program" and shared footer about
Associate / Effect / Fit being expressible in the DSL itself.
Old `OBJECTIVE_TIPS` constant removed; the "brokNA spell-checker"
typo is gone.
- **A9.** UI fitness label for TMB switched from `"association
(|spearman|)"` to `"negative association (signed βˆ’spearman with
TMB)"`; LiveView and held-out copy reworded to match. Engine
fitness already used signed βˆ’spearman.
- **Airgap stays absolute.** Only `stage`, `age`, `msi`, `tmb`
flow through engine_v2 as named values; gene IDs remain opaque
everywhere (program_repr, populations, API payloads). Airgap
tests parametrised over both engines Γ— both prefilter modes
stay green.
- **Chunk 6 follow-up (done β€” scoring target bound to the objective):**
A bug from chunk 6: under the TMB objective an engine_v2 run could
produce `Associate(Reduce(...), msi, spearman)` β€” the program freely
picked its own target. Fixed by removing the target as a selectable
operand:
- `engine_v2.synthesize` now threads `objective_target` through
`_grow_scalar`, `_grow_vector`, `random_program`,
`ramped_population`, and `mutate`. Every `Associate` / `Effect` /
`FitApply` is constructed with `target = objective.target`.
- Point mutation no longer carries a `fit_target` spot and the
`scalar_kind` spot no longer flips the target β€” only the
correlation kind (pearson / spearman) is mutable. Crossover never
touched targets (subtree swaps preserve them).
- `engine_v2.fitness._check_target_binding(program, expected)` walks
every Associate / Effect / FitApply node and asserts
`n.target == expected`. `fitness_fn` calls it first and floors any
mismatch to `WORST_FITNESS`, so a stray target β€” even from a
replayed tree β€” can never win.
- `program_repr` still shows the bound target (e.g.
`Associate(score, tmb, spearman)` under the TMB objective) so the
graph and paste-to-draw renderers don't change.
- The shared footer in all four objective `?` modals now reads
verbatim: *"What the program chooses is how to build the score and
how to compare it β€” a raw association (Associate) or a
confounder-adjusted one (Effect), plus the correlation kind. What
stays outside the DSL is the compass: the target it's scored
against, in which direction, judged honestly on held-out data.
The program can't pick the target β€” that would let the answer
into the language."*
- New tests: `test_synthesis_binds_scoring_target_to_objective`
(init + 600 mutations Γ— both objectives β€” every node binds to the
active target) and `test_fitness_floors_mismatched_target_to_worst`
(hand-crafted bug-shape program is floored to `WORST_FITNESS`).
80 / 80 pytest pass; the API airgap and gene-symbol-leak tests are
untouched.
- **Chunk 6 follow-up (done β€” Live view polish, presentation-only):**
- **Fitness curve chart.** Recharts' default `<Legend>` is gone; in
its place a custom `<ChartLegend>` HTML row sits below the chart
with SVG-line swatches that mirror the actual strokes (solid
accent for best, dashed muted for median). The X-axis title
("generation") is pulled OUT of the SVG and rendered as a centred
caption between the chart and the legend so the three rows
(ticks β†’ axis title β†’ legend) never overlap. Y-axis label uses a
`<CenteredYAxisLabel>` that rotates `<text>` at
`viewBox.y + viewBox.height/2` with `text-anchor="middle"` β€” the
title now sits beside the *middle* of the axis, not the top.
Lines: best solid `#3A6B7E` strokeWidth 2.5, median dashed (4 4)
`#6E7F8C` strokeWidth 1.5.
- **InfoTip copy.** `TIPS.fitnessCurve` and `TIPS.bestVsMedian`
rewritten verbatim β€” the y-axis tip explains negative-association
strength (with the MSI/AUROC fallback parenthetical); the
best-vs-median tip explains convergence vs. persistent gap.
- **Generation navigator (PopulationTiles).** Replaced the
click-only `β€Ή Gen N β€Ί` stepper with: `|β€Ή` first / `β€Ή` prev / a
draggable range slider / `β€Ί` next / `β€Ί|` latest / a numeric input
with a "go" button. Fetch decoupled from display via two states:
`draftGen` updates on every input event for live number, but the
`/runs/{id}/population/{gen}` fetch is keyed on `committedGen`,
which only updates on `pointerup`/`pointercancel` or a 150 ms
throttle while dragging. Keyboard: native left/right arrows
commit immediately; custom Home/End jump to first/latest. ARIA:
`aria-label="Generation"`, `aria-valuemin/max/now`,
`aria-valuetext`, and a polite live region for the "Generation N
/ total" label.
- **DSL vocabulary tiles cover the full grammar.** The Lab's "DSL
vocabulary" panel now lists all eight engine_v2 verbs (Select,
Reduce, Combine, Split, Associate, Effect, Fit/Apply, Search) so
the panel matches what programs in the population can actually
contain β€” earlier it only showed the three Vector-shape verbs and
silently omitted Scalar/Model-shape and gated ops. Presentation
only; engine grammar unchanged.
- **Chunk 6 follow-up (done β€” TMB-rank diagnostic, MMR biology panel,
MLH1 success reframe, real Unsupervised objective, live-view polish,
gene-based unsupervised synthesis):**
- **TMB-rank diagnostic.** New `validate/tmb_rank.py` and a small
`GET /diagnostic/tmb-rank` API endpoint. Mirrors `api._prepare_lab_data`
target="tmb" exactly (Load('processed') filtered to tmb.notna() &
expression-complete rows) and computes signed Spearman per gene
vs TMB on the NAMED matrix (vectorised rank-Pearson, sub-second).
Reports each MMR / IMMUNE gene's rank/N + the top-10
most-negatively-correlated genes. On the live TCGA cohort: MLH1
sits at rank 270 / 20057 (top 1.3%); PMS2 at 2214 (11%); MSH2/MSH6
not in expression (mutational, not silenced). The diagnostic lives
in `validate/` (allowed gene names) β€” the structural engine
airgap test still scans only `engine/`. 3 new tests in
`tests/test_tmb_rank.py`.
- **MMR biology panel.** `web/app/MMRBiologyPanel.tsx` mirrors
`ParameterFlow.tsx`'s collapsible exactly (button + chevron +
aria-expanded + useId-panel-id + closed-by-default). Body inlines
`mmr_reference.svg` verbatim (cause→effect: MMR → MSS / MSI-H → immune
response, plus the gene-expression visibility readout). Mounted
between the Objective card and Parameters card in `Lab.tsx`.
- **MLH1 success callout (TMB).** `/diagnostic/tmb-rank` is now
fetched at the Evaluator level (lifted out of the inner
`TMBRankPanel`) so the same response also drives an accent SUCCESS
callout above the overlap table β€” *"Blind, MLH1 ranks 270 / 20057
(top 1.3%) on the TMB objective β€” the causal gene surfaced near
the top without ever seeing gene names."* `OverlapSummary` gains
an optional muted secondary line so the "0 of N winning genes" read
no longer reads as flat failure.
- **Real Unsupervised objective (engine_v2).** New `UNSUP_OBJECTIVE`
with `target="none"`, `metric="structure"`. Fitness = silhouette of
a 2-means split on standardised 1-D score (range [-1, 1], worst
floored at -1.0). Vector-only programs: `V2Objective.synthesis_overrides()`
returns `{rates: {split:.1, fitapply:0, effect:0, search:0},
scalar_share:0}` for unsup, plumbed via `gp.py` into
`ramped_population` / `mutate`. `_build_ctxs` strips all labels
when target='none' (asserted) so the engine literally cannot see
msi/tmb during search. New `unsup_random_null` in `permutation.py`
β€” N random Vector-only programs scored on held-out (no target to
shuffle); `permutation_summary.null_kind` distinguishes the two
nulls in the result.
- **Post-hoc alignment.** Worker thread for unsup target runs
`_compute_unsup_posthoc` AFTER the GP finishes: orientation-agnostic
AUROC of winner scores vs MSI (held-out subset, gated nβ‰₯10 per class)
and `|spearman|` vs TMB. Lives in `api/_worker` (allowed labels) β€”
never in engine_v2. New `result.posthoc = {msi_auroc, tmb_abs_spearman,
n_holdout, n_msi_held, n_tmb_held}` rendered in `<PosthocAlignment>`
inside the ResultPanel.
- **Frontend β€” drop Survival, add Unsupervised end-to-end.** `Target`
widened to `"msi" | "tmb" | "none"`; `OBJECTIVE_PRESETS.none`,
`FITNESS_LABEL_BY_TARGET.none`. Survival entry removed from
`ObjectiveBuilder` + `paramHelpContent.tsx`. For unsup runs the
Evaluator hides the reference-set toggle / overlap / matched
column (no overlap concept) and falls back to the post-hoc block.
The `RunResult` interface in `web/lib/api.ts` gains `posthoc?:
Posthoc`.
- **Live-view + objective-modal clarity (presentation-only).**
Shortened y-axis titles (`tmb: "neg. association with TMB"`,
`none: "cluster separation (0–1)"`). Replaced the single TMB-centric
`TIPS.fitnessCurve` with `FITNESS_TIP_BY_TARGET: Record<Target,
string>` and plumbed `target` into `LiveView` so the InfoTip matches
the y-axis title's objective-awareness. Added `TIPS.nodes` and
`TIPS.genes` and passed them to the NODES + GENES `Metric` cards
in the ResultPanel. Rewrote `ObjectiveIntro` so it no longer
asserts "a target column" unconditionally; rewrote `obj_msi` /
`obj_tmb` / `obj_unsupervised` modal bodies verbatim per prompt.
`obj_unsupervised` no longer renders the target-centric
`ObjectiveFooter` β€” new `UnsupObjectiveFooter` ("no target, can't
pick one") in its place.
- **Silhouette guard hardened (engine_v2/fitness.py
`_score_silhouette`).** Winsorize the score to its 2.5–97.5
percentile range, then recompute std on the winsorized vector;
if `std_w < 1e-9` β†’ worst floor. This catches the
`protected_div`-by-self failure mode (huge raw std + a small
handful of outliers β†’ a 98:2 "perfect" split with silhouette ~1).
Min-cluster floor also raised from 10% to 30% of n.
`test_unsupervised_silhouette_kills_self_divide_outlier_split`
locks the fix.
- **Gene-based unsupervised synthesis (engine_v2/synthesize.py).**
Earlier unsup winners could be no-Select trees that did the
splitting work via `Split(..., predicate="stage_late")` β€” GENES=0,
silhouette 1.0 trivially, no actual gene discovery. Fixed by:
`_grow_matrix` now takes `objective_target`; under unsup every
Matrix leaf is `Select(MatrixTerminal(), sampled FeatureSet)`
(never bare `MatrixTerminal`), `Search` is gated off, and the
`_grow_vector` depth-floor becomes `Reduce(Select(M, [...]), agg)`.
`_grow_vector`'s Split branch forces `predicate="score"` under
unsup; point mutation's `predicate` spot is suppressed entirely
for unsup so a `"score"` can't be flipped to `"stage_late"` mid-run.
Belt-and-braces fitness floor: `fitness_fn` and `evaluate_holdout`
return `WORST_FITNESS` when `objective.target == "none"` and
`program.feature_ids() == []`. Two new tests
(`test_unsupervised_programs_always_use_gene_select` over 120
programs Γ— 20 mutations, and `test_fitness_floors_no_select_program_under_unsup`
on the literal bug-shape program).
- **Verification.** pytest 94/94, tsc clean. Live unsupervised
TCGA smoke (10 gens Γ— 60 pop, prefilter 2000) across four seeds
shows every winner is gene-based (1–5 genes), Split predicate is
`score` (no stage_late), held-out silhouette β‰ˆ 0.55–0.60 (not the
trivial 1.0), and the discovered split aligns blind with MSI at
AUROC up to **0.774** β€” the engine rediscovered the MSI subtype
without ever seeing the label.
- **Chunk 6 follow-up (done β€” generalisation-aware unsup silhouette +
plain-reading verdict):**
- **OOS silhouette in `cv_score` AND `evaluate_holdout`.** Earlier
unsup runs scored silhouette IN-SAMPLE inside CV (the KMeans was
fit on the same fold-test points it then evaluated) β€” so programs
that overfit looked great during selection and collapsed on the
final held-out (often to -1.0 worst floor). New
`V2Objective._score_oos_silhouette(train_scores, test_scores)`
fits the winsorize bounds, mean/std, and KMeans centres on TRAIN
scores only; test scores are clipped + standardised with the
train statistics and assigned to the train centres via
`KMeans.predict`. Guards live on the TEST side (β‰₯30% per cluster,
near-constant after clipping β†’ worst). `cv_score` per fold now
uses `(tr, te)` not just `te`. `evaluate_holdout` gained an
optional `ctx_train` kwarg; under unsup it executes the program
on TRAIN and TEST then calls the OOS scorer. MSI / TMB paths
ignore the new kwarg (those metrics are already on the held-out
vector by construction).
- **OOS null too.** `unsup_random_null` gained `ctx_train` and
forwards it into `evaluate_holdout` β€” otherwise the random
Vector-only nulls would be scored in-sample while the winner is
scored OOS, deflating the p-value. The pipeline now plumbs
`ctx_train` into both the winner's `evaluate_holdout` and the
null-distribution loop.
- **Plain-reading verdict at the top of the Result panel.** New
`<ResultVerdict>` rendered first inside `ResultPanel`. Per-target
thresholds (MSI 0.75, TMB 0.30, unsup 0.30) plus `p < 0.05` gate
a green "Real result: it holds up on unseen patients (X) and
beats chance (p Y)" callout vs an amber "This run found nothing
reliable: held-out X (fell apart on unseen patients) Β· p Y (a
random program beats it ~N% of the time). Don't read the genes
as a discovery." For unsup the green callout appends one of three
tails based on `posthoc.msi_auroc` (β‰₯0.75 β†’ "rediscovered the
subtype"; <0.75 β†’ "doesn't line up with MSI"; null β†’ "too few
held-out labels"). Constants `HOLDOUT_THRESHOLD`,
`P_VALUE_SIGNIFICANT`, `POSTHOC_MSI_ALIGN` at the top of
`Lab.tsx` are tunable.
- **Honest unsup numbers.** With OOS silhouette as the selection
metric the GP now selects for *consistent* two-cluster structure
rather than splits that happen to align with MSI via in-sample
overfitting. seed=13 used to report in-sample silhouette 0.6 +
MSI-AUROC 0.774 (alignment was partly an in-sample artefact);
now it reports OOS silhouette 0.900, p 0.024, MSI-AUROC 0.546 β€”
a real generalising cluster split that doesn't happen to be MSI.
The verdict surfaces that mismatch in plain words.
- **Four new tests** in `tests/test_engine_v2.py`:
`test_oos_silhouette_rewards_consistent_split` (train+test both
bimodal β†’ β‰₯0.70), `test_oos_silhouette_kills_non_generalising_program`
(train bimodal + test noise β†’ drops >0.3 below in-sample),
`test_oos_silhouette_train_only_constant_floored`,
`test_oos_silhouette_too_small_floored`. pytest 98/98 pass.
- **Chunk 6 follow-up (done β€” "What does this mean?" verdict
breakdown, presentation-only):**
- The verdict callout now has a collapsed-by-default disclosure
underneath it (mirrors `ParameterFlow.tsx`'s collapsible β€” button
+ chevron + `aria-expanded` + `aria-controls` + `useId`-keyed
panel + closed-on-first-render).
- Expanded content branches on `objective_spec.target`:
`<MSIBreakdownContent>` and `<TMBBreakdownContent>` use a one-
question shape ("Is this result real and useful?") with two
bullets β€” held-out separation/association and beats-chance β€”
plus a `β†’ Real and useful.` / `β†’ Not reliable…` capstone tied to
the same generalises/significant booleans the headline uses.
`<UnsupBreakdownContent>` uses the two-question shape from the
prompt verbatim ("Is the split real?" / "Is that split the MSI
subtype?") with the hair-colour analogy at the bottom. All
numbers route through `fmtFit` so non-finite renders as em-dash.
- **Chunk 6 follow-up (done β€” iterative unsupervised discovery,
"peel off axes"):**
- **Engine.** `engine_v2/pipeline.py` gained
`_residualise_matrix(M, residualize_scores)` β€” vectorised OLS via
`np.linalg.lstsq` over `[intercept, *priors]`. Rows missing a
prior score are dropped BEFORE the train/test split so train and
test see the same residualised feature space. Both
`run_v2_pipeline` and `run_v2_pipeline_streaming` gained
`residualize_scores: pd.DataFrame | None = None` (no-op for
MSI/TMB; only the unsup API worker sets it). Both functions now
emit `winning.full_scores` + `winning.full_sample_ids` (full-
cohort per-patient scores) alongside `holdout_scores` β€” these
feed the chain's next residualisation step.
- **API.** `RunRequest.residualize_against: list[str] | None`.
`_assemble_residualize_df` validates each prior (must exist + be
done + `target=="none"` + have `full_scores`); turns
`_json_finite`'s `None` back into `NaN` so the pipeline's NaN
filter drops those patients during residualisation. `post_runs`
validates the chain BEFORE spawning the worker β€” supervised
target + priors β†’ HTTP 400; unknown / wrong-target / no-scores
prior β†’ HTTP 400 with the specific reason. `_worker` gained
`residualize_against`; only assembles + forwards the DataFrame
for unsup runs. Chain is in-memory only β€” valid within a single
server session.
- **Frontend.** Lab.tsx grew an `axes: {run_id, result}[]` state.
`start()` refactored into `launchRun(opts?)` so a fresh Run
(`launchRun()`, clears `axes`) and `findNextAxis()`
(`launchRun({residualize_against: axes.map(a=>a.run_id)})`,
preserves the chain) share the same SSE wiring. The SSE `done`
handler appends unsup results to `axes`. New `<DiscoveredAxes>`
section under the Evaluator renders an ordered stack of compact
`<AxisCard>`s (accent border when both verdict gates pass;
holdout / p / revealed opaque IDs / post-hoc MSI AUROC + TMB
|spearman| in one line) plus a "Find next axis β†’" button. The
`TIPS.discoveredAxes` tooltip flags the linear-residualisation
heuristic and the in-memory-chain caveat.
- **Tests.** Extended `tests/test_api_airgap.py`:
`test_unsupervised_run_emits_posthoc_alignment` now asserts
`winning.full_scores` + `winning.full_sample_ids` are populated;
new `test_residualisation_chain_runs_and_stays_airgap_clean`
posts axis 1 then axis 2 with `residualize_against=[axis1]` and
confirms the second payload is opaque-only and carries its own
`full_scores`; new `test_residualize_against_unknown_id_returns_400`
+ `test_residualize_against_rejected_on_supervised_target`. 101 /
101 pytest pass.
- **Verified live on TCGA** (seed=13, 6 gens Γ— 50 pop, prefilter
2000):
- Axis 1 (5 genes, no overlap with MSI): holdout 0.597, p 0.065,
MSI-AUROC 0.552, TMB-|spearman| 0.009.
- Axis 2 (residualised against Axis 1; 5 different genes):
holdout 0.653, p 0.097, **MSI-AUROC 0.706**, TMB-|spearman|
0.248. Zero shared genes between the two axes β€” the
residualisation freed the search to land on an orthogonal
direction that's more MSI-aligned than the first.
- **Chunk 7 (done β€” second dataset: HNSC + HPV detection):**
- **Dataset pipeline.** New `data_pipeline/{download_hnsc,build_hnsc}.py`
mirror the colorectal scripts for TCGA HNSC PanCancer Atlas
(`hnsc_tcga_pan_can_atlas_2018`, LFS-resolved media URL). The
build script derives `hpv_status` ∈ {HPV+, HPVβˆ’} from whichever
column carries it: first `data_clinical_sample.txt::HPV_STATUS*`,
then `data_clinical_patient.txt::HPV_STATUS`, then the `SUBTYPE`
suffix fallback (e.g. `HNSC_HPV+` / `HNSC_HPV-`). On the real
cohort the resolved source is `patient.SUBTYPE`. Writes
`data/processed_hnsc/{clinical,expression}.parquet` (gitignored).
HNSC schema constants live in `data_pipeline/schema.py` next to
the colorectal ones; final usable cohort is 487 samples (72 HPV+ /
415 HPVβˆ’).
- **Loader.** `dsl.Load` already handled directory paths via its
`else` branch; the only change is that `Cohort.labels` now picks
up `hpv_status` if present. `Load(schema.HNSC_PROCESSED_DIR)`
returns the HNSC cohort with `hpv_status` in `cohort.labels`.
- **HPV objective (engine_v2).** `V2Objective.target` widened to
`Literal["msi","tmb","none","hpv"]`. New `HPV_OBJECTIVE`
(binary=True, worst=0.5) routes through the SAME
orientation-agnostic AUROC machinery as MSI; `score_scalar`
mirrors MSI's `abs(value)` orientation. `objective_from_spec`
dispatches `hpv + auroc/auroc_omni`. No new operators; no
other engine surface changes.
- **API per-(dataset, target).** `_prepare_lab_data(target, dataset)`
keyed by `f"{dataset}:{target}"`. New `(hnsc, hpv)` branch loads
HNSC, restricts to called HPV samples + complete expression,
anonymises, sets `y = (hpv_status == "HPV+").astype(int)`. New
`(hnsc, none)` unsup branch keeps every patient with complete
expression and carries the HPV label aside via `extra_labels` so
`_compute_unsup_posthoc` can compute orientation-agnostic
`hpv_auroc` (mirrors the `msi`/`tmb` branches; new key
`Posthoc.hpv_auroc?`). `RunRequest.dataset` + `Run.dataset` +
server-side `DATASET_TARGETS = {coadread: {msi,tmb,none},
hnsc: {hpv,none}}` validation rejects mismatched combos with a
400 at the API boundary before any data prep.
- **Per-dataset reference sets.** Replaced flat `REFERENCE_SETS`
with `REFERENCE_SETS_BY_DATASET`. Colorectal keeps MMR + immune;
HNSC ships `p16 = ["CDKN2A"]` and a 20-gene standard
cell-cycle / E2F-target core (MCM2–7 / PCNA / CDK1 / CCNE1 /
CCNB1 / CDC6 / CDC20 / MKI67 / TOP2A / RRM2 / TYMS / FOXM1 /
E2F1 / BUB1 / AURKB). `EvaluateRequest.dataset` is required and
cross-dataset reference-set requests return 400.
- **Frontend dataset registry.** `Target` widened to include `hpv`;
new `DatasetId = "coadread" | "hnsc"`. `DATASET_REGISTRY` in
`Lab.tsx` drives objective cards, biology panel, and reference-set
keys per cancer. A `<CancerSelector>` segmented control mounts
above the Objective row. New `<HPVBiologyPanel>` mirrors
`<MMRBiologyPanel>` exactly and inlines `hpv_reference.svg`
verbatim (RB / E2F / p16 / p53 / E6+E7 explainer + readout
table + airgap-side disclosure). Switching cancers resets the
chain + result state and snaps the objective to the dataset's
default.
- **HPV verdict + "?" coverage.** `FITNESS_LABEL_BY_TARGET.hpv =
"separation (AUROC)"`; `FITNESS_TIP_BY_TARGET.hpv` reuses the
MSI-style AUROC explanation worded for HPV+/βˆ’;
`HOLDOUT_THRESHOLD.hpv = 0.75`; `OBJECTIVE_PRESETS.hpv =
{target:"hpv", metric:"auroc"}`. New `<HPVBreakdownContent>` adds
a supervised one-question shape ("Is this result real and
useful?") β€” no two-question / hair-colour shape for HPV. Two new
paramHelp modals: `obj_hpv` (verbatim per prompt: what it
optimises, how it's scored, represented as, honest "detection /
recovery of a known viral signature β€” not new causation" note).
`<UnsupAlignmentTail>` + `<UnsupBreakdownContent>` +
`<PosthocAlignment>` all branch on which named label the cohort
carries (HPV vs MSI/TMB) so HNSC unsup runs surface a real
alignment number instead of "(too few held-out labels)".
- **Per-dataset Reveal.** `<Evaluator>` reads its reference-set
toggle keys from `DATASET_REGISTRY[dataset].refSetKeys`. HNSC
shows **p16 | cell_cycle**; colorectal stays on MMR | immune.
`<MLH1SuccessCallout>` + `<TMBRankPanel>` gated to
`dataset === "coadread" && target === "tmb"`. The "Unsupervised
run β€” no reference set" footnote reads dataset-agnostically.
- **Live verification (TCGA).** MSI seed 7 β€” held-out 0.959, p 0.048.
HPV seed 7 β€” held-out 0.869, p 0.048. HPV seed 11 β€” held-out
**0.970**, p **0.024**, winner overlaps `cell_cycle` via **MCM5**.
HPV seed 17 β€” held-out 0.957, MCM5 again. HPV seed 3 β€” E2F2 + RPA2
(cell-cycle/E2F + DNA replication, biologically on-target but not
in the literature set). Colorectal + airgap tests untouched and
green.
- **Airgap tests extended.** `tests/test_api_airgap.py` fake_prep
handles `(target, dataset)`; new tests:
`test_hnsc_hpv_run_is_airgap_clean`, `test_hnsc_rejects_msi_target`,
`test_coadread_rejects_hpv_target`,
`test_evaluate_rejects_hnsc_reference_in_coadread`. 105 / 105
pytest pass.
- **Chunk 7 follow-up (done β€” HPV-marker-rank diagnostic):**
Mirror of the TMB-rank diagnostic for HNSC. New `validate/hpv_rank.py`
ranks every gene by single-gene orientation-agnostic AUROC vs the
HPV+/HPVβˆ’ label on the engine's TRAIN split (`make_split(seed=42,
test_size=0.3, stratify=True)` so the diagnostic never reads test
data β€” same discipline the engine uses). Vectorised
Mann-Whitney / rank-sum formula:
`auroc = (S_pos βˆ’ n_posΒ·(n_pos+1)/2) / (n_posΒ·n_neg)` then
`max(AUROC, 1βˆ’AUROC)`; sub-second over ~20k genes. New
`GET /diagnostic/hpv-rank` endpoint mirrors `/diagnostic/tmb-rank`
(lazy import, `_HPV_RANK_CACHE`, `_json_finite()`-wrapped). Three
synthetic-cohort tests in `tests/test_hpv_rank.py`. Frontend:
`HPVRankDiagnostic` interface + `getHPVRankDiagnostic()` in
`web/lib/api.ts`; new `<HPVRankPanel>` in `Lab.tsx` reuses the
existing `<RankList>` chrome and renders **p16 + cell-cycle + top-N
separators** with the prompt's verbatim caption, gated to
`dataset === "hnsc" && target === "hpv"`. `TIPS.hpvRank` documents
the heuristic. Live TCGA cohort: N(TRAIN) = 340 (HPV+ 50 / HPVβˆ’ 290),
20,218 genes ranked. **CDKN2A AUROC 0.877, rank 99 / 20,218 (top
0.5%)** β€” meaningfully recoverable but 98 genes outrank it. Best
cell-cycle ranks: MCM5 (22), MCM2 (28), MCM6 (38), MCM3 (72),
PCNA (78), E2F1 (99, tied with CDKN2A). Top-10 led by TCAM1P /
C19orf57 / ARHGEF33 / STAG3 / SMC1B (meiosis-cohesin) + RPA2
(DNA replication β€” what the engine actually picked at seed 3). 108 /
108 pytest pass; airgap unaffected (engine_v2 + dsl + engine
untouched; structural scan still only walks `engine/`).
- **Chunk 7 follow-up (done β€” UX polish: plain "?" copy, no all-caps,
program-graph clipping):** Presentation-only sweep across the Lab.
- **Objective-aware tooltips.** Added four per-target / per-dataset
records next to `FITNESS_TIP_BY_TARGET`:
`HELD_OUT_TIP_BY_TARGET`, `PERMUTATION_P_TIP_BY_TARGET`,
`REFERENCE_SET_TIP_BY_DATASET`, `POSTHOC_TIP_BY_DATASET`,
`DISCOVERED_AXES_TIP_BY_DATASET`. Held-out / permutation p /
posthoc / discovered-axes / reference-set `?` all switch on the
active objective or dataset so HPV runs no longer see MSI/TMB
wording. `<DiscoveredAxes>` and `<PosthocAlignment>` receive
`dataset` (or infer it from `posthoc.hpv_auroc` presence) and look
up the right copy.
- **Plain English everywhere.** Rewrote the verbose / typo'd
legacy `TIPS.heldOut`, `TIPS.permutationP`, `TIPS.posthoc`,
`TIPS.discoveredAxes`, `TIPS.referenceSet` to crisp sentences with
one-line glosses for jargon (AUROC, held-out, permutation). Fixed
the `seed` PARAM_TIPS typo ("xact" β†’ "the exact"). The unsupervised
fitness-curve tip no longer hardcodes "(MSI? TMB?)" β€” it now says
"the named label this cohort carries".
- **ObjectiveIntro / UnsupObjectiveFooter dataset-aware.**
`ObjectiveIntro` now lists MSI / TMB / HPV (was MSI / TMB only).
`UnsupObjectiveFooter` mentions both MSI (colorectal) and HPV
(head & neck) as legitimate downstream alignments instead of MSI
only.
- **No all-caps anywhere.** Stripped Tailwind's `uppercase` utility
from all 13 eyebrow / table-header sites in `Lab.tsx` +
`PasteToDraw.tsx`. Removed `textTransform: "uppercase"` from the
Tier label in `ProgramGraph.tsx`. Upcased the underlying literal
strings that were relying on the CSS (`Winning program`,
`Revealed genes`, `Opaque ID` / `Symbol` / `Matched` table
headers, rank-table `Symbol` / `Correlation` / `Rank / N` /
`AUROC`). `grep -rn "uppercase\|textTransform" web/app` returns
zero hits.
- **Program-graph clipping fix.** `ProgramGraph.tsx` gained a
remount `key={fitKey}` derived from `width Γ— height Γ— node-count
Γ— edge-count` so ReactFlow's one-shot `fitView` re-runs every
time the laid-out program changes (winner ↔ candidate click,
paste, dataset swap). Lowered `minZoom` from 0.4 to 0.2 so
`fitView` can scale a full two-tier graph down to fit the card
width. Confirmed no `overflow: hidden` on the wrapping
`<SectionCard>`. `<ProgramGraph outputLabel>` now dispatches by
target β€” `MSI-H probability` / `HPV+ probability` /
`TMB association` / `cluster score` instead of MSI-only.
- **Chunk 7 follow-up (done β€” detection vs recovery + AUROC naming +
ban whole-matrix Reduce + per-gene rank in Reveal):**
- **Engine.** Motivating live run produced
`Combine(Reduce(M, mean), Reduce(Select(M, …), mean), add)` β€” a
bare global-mean detector with a 7-gene additive tail; held-out
AUROC 0.953 with `0/7` cell-cycle overlap. The dominant term
detected HPV from bulk expression, not from gene choice. Fix:
`engine_v2/synthesize.py` makes the Select-wrapping rule
**unconditional** for every objective (was unsup-only). Every
Matrix leaf is now `Select(MatrixTerminal, FeatureSet)`; the
Vector depth-floor returns `Reduce(Select(M, …), agg)`; the
bare-MatrixTerminal fall-through is gone. Belt-and-braces in
`engine_v2/fitness.py`: new `_has_bare_matrix_reduce(program)`
walks the tree and any Reduce on a bare MatrixTerminal floors to
`WORST_FITNESS` for every objective (was unsup-only). The
`no Select β†’ WORST_FITNESS` floor is also no longer gated to
unsup. Two test fixups: the closed-Matrix leaf at depth 0 is now
`Select(MatrixTerminal, …)`; the depth-budget contract is loosened
by 1 because the mandatory Select adds a level. Live re-run on
real HNSC (seed 7): winner is now `Reduce(Select(M,[g17705]),max)`
β€” a single-gene detector, AUROC 0.964, no bare `Reduce(M,…)`.
- **Detection vs recovery (presentation).** `HPVBreakdownContent`
+ `MSIBreakdownContent` success copy changed from "the engine
recovered the HPV viral signature" / "Real and useful." to
"Real and useful β€” it separates HPV+ from HPVβˆ’ (or MSI-H from
MSS) on patients it never saw." Both breakdowns gained a muted
secondary line β€” *"This is a detection result. Whether the
engine found the known marker genes (CDKN2A/p16, the cell-cycle
program / MMR / immune) is a separate question β€” see Reveal &
evaluate below."* The top-level callout already used detection-
only wording. Unsupervised "rediscovered the viral signature
blind" stays β€” that's a post-hoc *alignment* claim, gated on
`posthoc.hpv_auroc β‰₯ 0.75`.
- **Name AUROC where the metric IS AUROC.** New
`HELD_OUT_LABEL_BY_TARGET` β€” Held-out card label is `Held-out
AUROC` for MSI / HPV, `Held-out (|spearman|)` for TMB,
`Held-out (silhouette)` for unsup. `HELD_OUT_TIP_BY_TARGET`
rewritten to name AUROC explicitly with a one-line gloss for
MSI/HPV and to call out "Not AUROC: TMB is a continuous label /
there's no label here" for TMB/unsup. Held-out bullet inside
each breakdown switched `Held-out separation` β†’ `Held-out AUROC`
with the same gloss.
- **Per-revealed-gene rank in Reveal & evaluate.** `/evaluate`
request gains optional `target`; response rows gain optional
`rank` / `total` / `single_gene_metric` / `metric_kind`
(`"auroc"` for HNSC/HPV, `"spearman"` for coadread/TMB). New
`_gene_rank_lookup(dataset, target)` precomputes a
`{symbol β†’ rank/metric}` map on first call by reusing
`validate/hpv_rank.py`'s `_auroc_per_column` (on the engine's
TRAIN slice) and `validate/tmb_rank.py`'s `_spearman_per_column`;
cached for the process lifetime. Frontend: `EvaluateRow` gained
the optional fields; `postEvaluate` passes `target`; the
Revealed-genes table now shows a "Single-gene rank" column with
`{rank} / {total}` plus `AUROC X.XXX` (HPV) or `ρ X.XXX` (TMB).
Column appears only when at least one row carries a rank β€” MSI
and unsupervised omit gracefully. New `TIPS.singleGeneRank`
explains the column: "rank near 1 = real alternate marker; high
rank = only helps in combination."
- **Verified live.** HPV seed 11 on real HNSC (10 gens Γ— 80 pop):
winner `Reduce(Select(M,[g06264,g08575,g10783,g14446,g18989,
g19553,g05351,g10525]),mean)`, held-out AUROC 0.937, p 0.024.
Cell-cycle overlap **0/8**, BUT every revealed gene is a top-2000
single-gene HPV separator on its own: FANCI (rank 996, AUROC
0.779), INPP5B (332, 0.830), MIR924HG (237, 0.841), RAD1 (359,
0.825), UBD (1808, 0.746), WHSC1 (402, 0.821), DYRK1A (246,
0.840), MCCC1 (1334, 0.763). The story is now legible: real
alternate markers (DNA-damage-response / chromatin players), not
the canonical MCM / PCNA shortcut. 108 / 108 pytest pass; tsc
clean; airgap tests untouched.
- **Chunk 7 follow-up (done β€” coherence prior + supervised peel-off +
full-ranking Result + Unsup card retired):** Five coordinated
landings that rebuild the Lab's reveal story around the full single-
gene ranking.
- **Coherence prior (engine).** New
`engine_v2.fitness._coherence_score(program, ctx)` returns the
mean absolute pairwise correlation among the program's Select'd
opaque columns (range [0, 1]; names no gene or pathway).
`fitness_fn` gained `coherence_weight: float = 0.0`; when > 0,
fitness becomes `base βˆ’ λ·n_nodes + w Β· coherence`. Threaded
through `gp.py` + both `pipeline.py` entry points with default 0
so existing runs stay byte-for-byte unchanged. API: new
`RunRequest.coherence: bool = False`; `_worker` translates to
`COHERENCE_DEFAULT_WEIGHT = 0.10` (modest enough that separation
still dominates). Frontend: a Parameters checkbox "Prefer
coordinated gene modules" β€” default OFF, posted in the run body.
`TIPS.coherence` carries the prompt's verbatim copy.
- **Peel-off for supervised objectives.** Both pipeline entry
points now persist `winning.full_scores` + `full_sample_ids` for
every objective (the `is_unsup` gate is gone in both spots).
`api.app.post_runs` no longer requires `target=="none"` for
`residualize_against`; `_assemble_residualize_df` validates that
every prior shares both `(dataset, target)` with the new run.
Frontend `<DiscoveredAxes>` mounts after ANY run (not just unsup)
and the SSE `done` handler appends to the chain for every
objective. Live verified on HNSC HPV seed 11: Axis 1 β†’ AUROC
0.941 / 3 genes; Axis 2 (residualised) β†’ AUROC 0.875 / 2
different genes; zero overlap. Updated airgap test:
`test_residualize_against_works_on_supervised_targets` posts MSI
Axis 1 β†’ Axis 2 and asserts payload stays opaque + carries
full_scores.
- **Full-ranking endpoint.** New `GET /diagnostic/full-rank?dataset=
&target=` returns `{n_samples, n_pos, n_neg, n_genes, metric_kind,
ranks: [{opaque_id, score, rank}, …], reference_marks: [{opaque_id,
symbol, set_name, rank, score}, …]}`. The `ranks` list is
opaque-only (~20k rows). `reference_marks` is the small reference
gene set (CDKN2A + 20 cell-cycle for HNSC; MMR + immune for CRC),
looked up via the sealed map on the API side and revealed up-front
so the rank track can label them β€” the whole map never crosses
the wire. Computed on the same TRAIN slice the engine sees
(`make_split(seed=42, test_size=0.3, stratify=binary)`), using
the rank-sum AUROC formula for binary targets and signed Spearman
for TMB. Unsupervised β†’ 404 (no label to rank against). Cached
per (dataset, target) for the process lifetime.
- **New `<RankingResult>` UI.** Replaces the prior
Evaluator + TMBRankPanel + HPVRankPanel + MLH1SuccessCallout for
supervised targets. Renders, top-to-bottom:
- Rank chart: a labelled lollipop chart on a log-rank axis
(gridlines at 1 / 10 / 100 / 1k / 10k / N), with the winner
gene(s) as accent-teal dots and each reference gene as a
colour-coded dot. Labels stagger across 4 rows to avoid
collisions near the top.
- Highlighted-genes table with three-way colour-coded "Source"
column (winner / p16 / cell_cycle for HNSC; winner / immune /
MMR for CRC) β€” p16 reads visibly distinct from cell_cycle
via a separate deep-gold palette. Columns: Source Β· Symbol Β·
Opaque ID Β· Rank/N Β· Percentile Β· metric. Percentile shows
"top 0.5%" for high ranks and flips to "bottom 21%" past the
midpoint (2 sig figs).
- Collapsed `<RawRankingDisclosure>` ("Raw anonymous ranking β€”
proof the ranking is computed blind on opaque IDs") wraps the
browsable opaque-ID list + search box (top-200 with substring
filter). Caption restates the airgap purpose.
- GP-vs-diagnostic note (verbatim per prompt) closes the panel.
- **Unsup card hidden, backend kept.** `DATASET_REGISTRY` drops
the `none` objective entry for both datasets β€” UI now shows
coadread β†’ {MSI, Mutation burden} and hnsc β†’ {HPV detection}.
`UNSUP_OBJECTIVE`, the unsup pipeline branches, the
`target === "none"` frontend code paths (verdict / breakdown /
post-hoc / Evaluator fallback), and `tests/test_engine_v2.py`
stay intact β€” just unreachable from the UI.
- **108 / 108 pytest pass; tsc clean; airgap tests still green.**
- **Chunk 7 follow-up (done β€” Ranking UI polish):** Presentation-only
pass on `<RankingResult>`. New three-way `RANK_COLORS` palette
(winner accent teal / p16 deep gold / cell_cycle muted amber, plus
immune/MMR aliases for CRC) drives every coloured surface β€” chart
dots, table backgrounds, "Source" badges. Highlighted-genes table
gains a "Percentile" column (`top X%` / `bottom X%` with 2 sig figs)
so CDKN2A reads "99 / 20,218 Β· top 0.5%" and CCNE1 reads
"15,918 / 20,218 Β· bottom 21%". `<RankTrack>` rewritten with a log
axis (gridlines at 1 / 10 / 100 / 1k / 10k / N), a 4-row label
stagger that avoids 60px-radius collisions, and an inline
three-colour legend in the chart header. Raw opaque-ID browse
table moved into a collapsible disclosure ("Raw anonymous ranking
β€” proof the ranking is computed blind on opaque IDs"), closed by
default. Page reads verdict β†’ metric cards β†’ log chart β†’ highlighted
table β†’ collapsed raw β†’ GP-vs-diagnostic note. tsc clean; no
engine/API/airgap changes.
- **Chunk 7 follow-up (done β€” peel-off leakage fix: train-only
residualisation + FitApply train-fit + leakage guard):** A peel-off
Axis 2 HPV run produced
`FitApply(Reduce(Select(M,[g16970]),median),hpv)` (SPACA1) at
held-out AUROC 0.963 β€” impossible for a single-gene monotonic
program whose gene's honest single-gene AUROC is 0.510. Root
cause: residualisation was fit on the FULL cohort BEFORE
`make_split`, so the OLS projection saw the held-out rows and
smeared target signal into them.
- **`engine_v2/pipeline.py`** β€” `_residualise_matrix` replaced
with three helpers: `_align_priors` (drops rows missing a
prior β€” defining the cohort, not leakage), `_fit_residualise_beta`
(OLS of each gene on `[intercept, *priors]` fit on TRAIN rows
only), `_apply_residualise` (applies train-fit Ξ² to the full
M so train + test sit in the same residualised space without
test ever being seen by the fit). Both `run_v2_pipeline` and
`run_v2_pipeline_streaming` reordered: align β†’ split β†’ fit Ξ²
on `M.loc[split.train_ids]` β†’ apply Ξ² to full M β†’ prefilter /
GP / `evaluate_holdout`. `winning.full_scores` re-executes the
winner on the train-only-residualised M with empty labels (the
raw inner score for FitApply winners β€” monotonic with the
fitted prediction in the 1-D case, so the chain stays
consistent).
- **`engine_v2/nodes.py`** β€” `ExecContext` gained
`fit_ctx: ExecContext | None = None`. `FitApply.execute` now
runs the inner subtree on `fit_ctx` (train) to fit LR/OLS and
applies the FROZEN model to the test inputs (no fitting on
test labels). Binary `hpv` now routes through the same
logistic branch as `msi`. Identical behaviour in the single-
score monotonic case (LR remains monotonic) so existing AUROCs
don't move; the discipline is in place for any future multi-
input FitApply.
- **`engine_v2/fitness.py`** β€” `LEAKAGE_TOLERANCE = 0.02`.
`evaluate_holdout` wraps the test `ExecContext` with
`fit_ctx=ctx_train` when `ctx_train` is provided, then runs a
backstop check: if the program is `_single_gene_monotonic`
(one gene; no Combine/Split) AND its held-out AUROC exceeds
the gene's single-gene AUROC by > tolerance, floor to
`WORST_FITNESS`. Genuine multi-gene synergy is unaffected.
- **Tests** β€” `tests/test_engine_v2.py` gained
`test_peeloff_residualisation_uses_train_rows_only`
(synthetic cohort + target-correlated prior; asserts the
single-gene-monotonic held-out AUROC is within tolerance of
the gene's honest single-gene AUROC under the new pipeline)
and `test_leakage_guard_floors_contaminated_single_gene_winner`
(guard sanity-check on an honest program). 114 / 114 pytest
pass; airgap suite untouched.
- **Verified live (TCGA HNSC, HPV + coherence ON).** Across
seeds {3, 7, 11, 13} every Axis 2 is multi-gene, no
SPACA1-style runaway wins, and Axis 2 held-out ≀ Axis 1 +
tolerance: seed 11 β†’ Axis 1 AUROC 0.941 / 3 genes β†’ Axis 2
AUROC 0.923 / 8 genes (zero gene overlap). The
residualise-before-split mechanism is closed.
- **Chunk 7 follow-up (done β€” coordinated modules: ranked group
view + ref-set highlights):** Coherence-on runs now expose a
ranked list of the coordinated gene modules the GP explored,
sorted by combined held-out AUROC β€” so a real co-expressed
program (e.g. cell_cycle) surfaces as a GROUP even when no
single gene is the top separator.
- **API.** New `GET /runs/{run_id}/modules` endpoint
(`api/app.py::_compute_module_ranking`). Reproduces the run's
EXACT train/test split via the persisted `full_sample_ids` /
`holdout_sample_ids` so "held-out" actually is held-out;
harvests distinct candidates' `gene_ids` sets (β‰₯ 2 genes)
across every persisted generation (engine_v2 already stores a
flat `gene_ids` list per candidate); for each module computes
**combined held-out AUROC** = orientation-agnostic AUROC of
the per-patient mean across the module's genes vs the held-
out label (parameter-free β€” nothing to fit, nothing to leak;
`|spearman|` for TMB), **coherence** = mean abs pairwise
correlation on TRAIN (the same quantity the prior rewards),
and a per-gene single-gene AUROC + rank/N. Each module also
carries **`ref_sets: list[str]`** β€” the dataset's reference
sets (HNSC: p16 / cell_cycle; CRC: MMR / immune) whose
opaque IDs intersect that module, resolved ONCE via the
sealed map (bounded reveal of a small known set; same
pattern `/diagnostic/full-rank` uses for `reference_marks`).
Sorted by combined held-out desc; non-finite sinks to the
bottom. `Run` dataclass gained `coherence: bool`; `/runs/{id}`
now surfaces it (along with `dataset`). Endpoint returns 425
while running, 404 for unknown, 400 for unsupervised runs
(no target to evaluate against).
- **Frontend.** New `<ModuleRankingPanel>` rendered after
`<RankingResult>`, gated to `target !== "none" && coherence
&& runId`. Sortable header (Combined AUROC / Coherence);
each collapsed row carries a small **badge per matched
reference set** and is **tinted** using the existing
three-way `RANK_COLORS` palette (winner accent teal /
single-marker sets like p16/immune deep gold / broader
proliferation sets like cell_cycle/MMR muted amber) β€” so a
cell_cycle-heavy module is scannable without expanding.
Expanding a row calls **`POST /reveal`** with just that
module's gene IDs (bounded β€” never the whole map) so every
per-gene row gets a symbol (no more "β€”" for module genes
outside the already-revealed subset, e.g. `g09647 β†’ LIG1`).
Expanded view gains a "Source" column tinted per-symbol's
reference-set membership.
- **Airgap.** Module ranking carries opaque IDs + scores +
`ref_sets` set NAMES only β€” no gene symbols. Reference-set
membership comes from the same bounded sealed-map lookup
`/diagnostic/full-rank` already uses. Per-module symbols
revealed lazily per expanded module via `/reveal`. New
airgap tests in `tests/test_api_airgap.py`:
`test_modules_endpoint_returns_opaque_only_modules` (assert
`ref_sets` list present, opaque IDs only, sorted desc by
combined AUROC), plus rejection tests for unsupervised /
unknown / mid-run scenarios. 114 / 114 pytest, airgap suite
25 / 25, `tsc --noEmit` clean.
- **Verified live (HNSC HPV, seed 11, coherence ON).** 303
distinct modules; the rank-1 module (size 11, combined
held-out AUROC **0.978**) carries the cell_cycle badge and
beats the winner's 0.941. Expanding it resolves all 11
symbols on the wire: MCM2, CENPQ, FAM111B, KIF2C, NDC80,
RAD1, USP1, REXO2, ING4, KIAA1407, MMP17 β€” a proliferation
/ DNA-replication module the single-gene picture missed.
Exactly the "group view can out-score its individual genes"
demonstration the design called for.
- **Chunk 7 follow-up (done β€” separate group vs individual views in
the coherence-run UI):** Presentation-only cleanup so no single
table mixes group and individual scores under the same column.
- **Single-gene "Highlighted genes" table** retags winner-source
rows from `"winner"` β†’ `"in winning program"` and adds a one-line
caption underneath: *"These are single-gene ranks of individual
genes β€” including the genes inside the winning program and the
reference markers. The winning program's combined (group) score is
in the Result cards above and the Coordinated modules below."*
The winner's combined held-out is unchanged in the Result cards.
- **InfoTip copy rewritten** for both panels. `TIPS.rankingResult`
+ `TIPS.rankingHighlighted` lead with INDIVIDUAL; `TIPS.module
Ranking` leads with GROUP and explicitly states that the modules'
re-scoring (mean across the gene-set vs label on held-out) is a
different number than the GP search used, so the #1 module can
differ from β€” and even beat β€” the winning program.
- **Coordinated-modules table** now tags the module whose gene-set
is exactly equal to the winner's `gene_ids` (unordered set
equality) with a `winner` badge in the existing accent-teal
palette. That row sits at its scored rank, not at the top, so the
user can see what rank the winner occupies under the group metric
(e.g. seed 11 / HPV: winner program holdout 0.9411 vs the same
gene-set as a module β†’ 0.9153 β†’ rank 41; #1 module 0.9778 β€” three
different numbers, three honest meanings).
- **Paginated auto-reveal** replaces the "Reveal genes" per-row
click. `MODULE_PAGE_SIZE = 25`; symbols cached in a
`Record<string, string>` keyed by opaque ID and ONE batched
`/reveal` call per page (bounded by ≀ 25 Γ— `max_genes_per_set`
IDs β€” never the whole map). Page-1 of the HNSC seed 11 run pulls
99 unique IDs out of ~20k. New `<ModulePager>` (|β€Ή β€Ή page n/N β€Ί β€Ί|)
+ a row click (β–Ά / β–Ό) toggles the expanded per-gene-metrics view.
Sort or fresh-run resets page + collapses any open row. Inline
symbol list truncates at 8 with a `Β· +N` overflow chip; expanding
shows them all. The expanded `<ModuleGeneTable>` reads from the
shared `symbolByOpaque` cache (no second fetch).
- **No engine/API/airgap changes.** `tsc --noEmit` clean;
`pytest -q` 114 / 114; airgap suite untouched.
- **Chunk 7 follow-up (done β€” confound pass: configurable Effect
confounders + site / purity survival flags on module ranking):**
Lets us tell whether a gene/module separates HPV for a *mechanistic*
reason or via a *confounder* (sex / race / anatomic site / immune
composition). Airgap stays absolute β€” clinical confounders (sex /
race / oropharynx flag) and the derived purity proxy are named
non-gene variables in the data + validation layer; engine_v2 only
ever sees opaque gene IDs.
- **HNSC build.** `data_pipeline/build_hnsc.py` now writes `race`,
`ethnicity`, `tissue_site` (raw TUMOR_TISSUE_SITE),
`icd_o_3_site` (ICD-O-3 topography from the patient file), and a
derived **`is_oropharynx`** boolean (SEER mapping: ICD-O-3 prefix
C01/C09/C10 + exact C02.4/C05.1/C05.2). Prints non-null counts
and the top ICD-O-3 distribution so the mapping is auditable. On
the live cohort: 72 oropharynx / 415 not / 0 NaN among 487 called
samples; HPV+ concentrates in the oropharynx (49/72 = 68%) vs
HPV- (23/415 = 5.5%) β€” the confound is real and large.
- **dsl.Load** widens its allowed clinical columns to include
`race`, `ethnicity`, `tissue_site`, `icd_o_3_site`, `is_oropharynx`
β€” filtered per cohort (coadread's parquet doesn't have these, so
nothing changes there).
- **API `_prepare_lab_data`** widens the HNSC clinical subset to
`(stage, age, sex, race, tissue_site, icd_o_3_site, is_oropharynx)`
when present (skips columns not in the frame). Coadread unchanged.
- **`Effect` is now configurable.** `ExecContext` gained
`confounders: tuple[str, ...] = ("stage", "age")` β€” default
preserves byte-for-byte legacy behaviour (MSI / TMB / HPV runs
unchanged). `Effect.execute` reads `ctx.confounders` and builds
a one-hot-encoded design matrix from any columns present in
`ctx.clinical` (age continuous, everything else categorical;
missing values drop the row). Pipeline + worker plumb the
confounder set in β€” HNSC v2 runs use
`("stage","age","sex","race")` automatically when those columns
are in clinical. Smoking deliberately NOT included (not in this
download). New tests: `test_effect_default_confounders_are_stage_and_age`
+ `test_effect_extended_confounders_include_sex_and_race`
(asserts the extended path runs and unknown column names like
"smoking" are silently skipped).
- **Module ranking: site-stratified survival flag.**
`_compute_module_ranking` no longer discards `clinical`. For
HNSC/HPV runs it slices `is_oropharynx` to the held-out test
patients, recomputes each module's mean-aggregate AUROC within
the oropharynx subgroup, and emits `combined_holdout_oropharynx`
+ `survives_site` (True iff stratified AUROC β‰₯ full βˆ’ 0.05).
Modules whose "HPV signal" was really an oropharynx-tissue
marker visibly collapse here.
- **Module ranking: purity-proxy survival flag.** Immune-
infiltration proxy = per-patient mean expression of CD8A / GZMB /
PRF1 / CD3D / CD2 (`HPV_IMMUNE_PROXY_GENES`, curated once in
`api/app.py`), resolved to opaque IDs via the sealed map β€” same
bounded-reveal pattern `/diagnostic/full-rank` uses for
`reference_marks`; never leaves the API layer. Bottom tertile
of the proxy on TEST = high-purity subset; each module gets
`combined_holdout_highpurity` + `survives_purity`. On the live
HNSC cohort the high-purity TEST subset is ~49 patients but with
only ~2 HPV+ (class imbalance β€” HPV+ tumours have more immune
infiltrate), below the n_pos β‰₯ 5 guard, so the flag honestly
reports "n too small" rather than a misleading AUROC.
- **Module endpoint payload** gains `subgroups: { site, purity }`
metadata (kind / n / tolerance / n_proxy_genes) at the top level
so the UI can render the column label + an explanatory caption.
Opaque-only on the wire β€” no gene NAMES enter the module
payload.
- **Frontend.** New `<SurvivalChips>` renders compact
`site βœ“ 0.92` / `site βœ— 0.84` / `purity β€”` chips on each module
row (gated to whichever subgroups the server emitted; non-HNSC /
non-HPV runs don't show the column at all). Expanding a module
appends `<StratifiedAUROCStrip>` β€” full vs oropharynx vs
high-purity AUROCs with n + per-class counts. New `TIPS.module
Survival` `?` explains the flags. `TIPS.moduleRanking` gained
the verbatim winner's-curse caveat: *"these groups are scored by
re-evaluating ~2,900 explored sets on the same small held-out
set and showing the best β€” so the very top values are
optimistically biased (the luckiest of thousands). The winning
program was chosen by cross-validation, which guards against
that, so trust it as the engine's pick even when a table row
scores higher."*
- **Verification.** `pytest -q` 116 / 116 (was 114; +2 Effect
confounder tests). Airgap suite 25 / 25 green β€” module payload
still opaque-only. `tsc --noEmit` clean. Live HNSC seed-11
coherence-on: 303 modules, 234 survive the oropharynx subgroup
βœ“, 69 collapse βœ—, 0 n/a; purity all n/a (class imbalance, as
expected). The winner module (the 3-gene Combine β€” full AUROC
0.915 in this module-metric view) survives the oropharynx test
(subgroup AUROC 0.919) β€” it's genuine HPV signal that holds
within oropharynx, not a tissue marker.
- **Chunk 7 follow-up (done β€” Result views consolidation: GP's
group vs known markers vs explored leaderboard):** Presentation
refactor so the three Lab Result panels map cleanly to three
questions and no single table mixes a group entry with individual-
gene rows. No engine / API / airgap change.
- **Survival chips show full β†’ subgroup.** `<SurvivalChips>` (was:
`site βœ“ 0.95`) now renders `site βœ“ 0.97 β†’ 0.95 (βˆ’0.02)` so the
user sees both the pre and post AUROC plus the drop. Pre =
`module.combined_holdout`; post = the stratified AUROC. The
"n too small" path drops the arrow and renders `site β€”`
(unchanged). Tooltip wording updated to spell out the full-cohort
β†’ subgroup pair.
- **Known-marker recovery panel = reference markers only.**
`<RankingResult>`'s `pinnedRows` no longer pushes the winner's
`kind:"winner"` rows; only reference-set rows survive. SectionCard
title becomes **"Known-marker recovery"**, the heading flips from
*"Highlighted genes"* to *"Known markers"*, the lollipop chart
auto-titles to *"Where the known markers rank"* (and drops the
winner legend swatch) when `winnerOpaqueIds=[]`. The captions
and `TIPS.rankingResult` / `TIPS.rankingHighlighted` rewritten to
state this is a known-marker recovery diagnostic and point users
to the Result panel for the winner's group view. The raw-anonymous
list still tints rows that match the winner β€” that's per-row
highlighting, not a pinned-source mix.
- **Result panel = self-contained "GP's group" story.**
`<ResultPanel>` now takes `dataset` / `target` / `runId` /
`coherence` from the Lab parent and fetches
`getFullRankDiagnostic` (per-gene single-gene ranks) +
`getRunModules` (for the winner's survival chips) β€” same
endpoints already used elsewhere; the server caches them.
Reveals the winner's symbols via `postReveal(winner.gene_ids)`
(bounded β€” only the winner). New `<WinnerGroupBlock>` rendered
under the metric cards:
- **Its genes, each on its own:** for each `winning.gene_ids`,
looks up its rank in `diag.ranks` and shows
`SYMBOL #rank / N` (e.g. `RNF32 #123 / 20,218 Β· PCBD2 #79 /
20,218 Β· RPS10P7 #2344 / 20,218`).
- **Confound survival:** finds the winner's own module in
`moduleData` via unordered gene-set equality (same notion
`<ModuleRankingPanel>::isWinnerSet` uses), then renders
`<SurvivalChips>` β€” automatically inheriting the Part-A
pre→post pairing. Gated to runs that emit survival metadata
(HNSC/HPV coherence-on); omitted gracefully otherwise.
- Block InfoTip frames the three-panel layout: *"This is the
group the engine actually chose. Below: how each of its genes
ranks on its own, and whether the group survives the confound
checks. The known-marker recovery diagnostic and the explored-
group leaderboard live in the next two panels β€” different
questions."*
- **Verification.** `tsc --noEmit` clean; `pytest -q` 116/116
(no Python changes). Live HNSC seed-11 coherence-on: winner
RNF32+PCBD2+RPS10P7 AUROC 0.941, the three per-gene ranks resolve
(#123 / #79 / #2344), winner module survives the oropharynx
subgroup (site βœ“ 0.915 β†’ 0.919) and reports purity n/a (class
imbalance). The known-marker panel shows only MCM5 #22 / MCM2 #28
/ MCM6 #38 / … with no winner dots/rows. The Coordinated-modules
table still badges the winner's own set at its scored rank.
- **Chunk 7 follow-up (done β€” Result panel: GP's top-10 programs +
plain-language modules copy):** Expanded the single-row "GP's
group" block into the engine's top-10 programs ranked by its OWN
fitness, and rewrote the Coordinated-modules copy in plain
English. Presentation + frontend data-flow only.
- **TopProgramsBlock** replaces `<WinnerGroupBlock>` in `<ResultPanel>`.
`<ResultPanel>` now fetches `getRunStatus(runId)` β†’ last
`generations_persisted - 1` β†’ `getRunPopulation(runId, lastGen)`,
dedupes the `candidates` by unordered UNIQUE-gene-set (engine_v2
programs can carry repeated genes across multiple Selects), keeps
the highest-fitness representative per set, sorts by fitness
desc, and takes the top-10. Each row reuses the existing
treatment: per-gene `SYMBOL #rank / N` (looked up in `diag.ranks`)
and `<SurvivalChips>` for the program's matching module (gene-set
equality the same notion `ModuleRankingPanel::isWinnerSet` uses).
#1 is the winner and is highlighted with the accent-teal palette +
a `winner` chip.
- **Bounded reveal**. The reveal cache (`symbolsByOpaque`) is
populated by a single batched `postReveal` over the UNION of all
top-10 programs' gene_ids β€” never the whole map. Live HNSC seed-
11 coherence-on: 15 unique opaque IDs across the top-10.
- **Modules subtitle + InfoTip rewritten in plain English.** The
Coordinated-modules `<SectionCard>` subtitle is now: *"Not the
engine's picks. After the run, this re-scores every gene group
the engine tried β€” using one simple number (the group's average
expression, measured on held-out patients) instead of how the
engine judged groups during the search. So this list can rank
groups differently from 'The GP's top programs' above, and its
#1 can even beat the engine's winner."* `TIPS.moduleRanking`
rewritten verbatim to the three-paragraph "What this is / How
it differs / Caveat" structure from the prompt; the winner's-
curse paragraph now reads as plain advice ("trust the engine's
own picks above as the reliable choice") rather than statistical
jargon. `TIPS.rankingResult` reworded to point at the GP's top
programs and the after-the-fact re-scoring panel by name.
- **Verification.** `tsc --noEmit` clean; `pytest -q` 116/116.
Live HNSC seed-11 coherence-on: GP top-10 by fitness has the
winner (RNF32+PCBD2+RPS10P7) at #1 with GP fitness 0.924; runner-
ups are small 2-3-gene programs around the same core. Module
ranking by combined held-out AUROC has an 11-gene cell-cycle-
flavoured set at #1 (combined 0.978) β€” a different list, exactly
the divergence the new copy makes explicit.
- **Chunk 7 follow-up (done β€” cheap diversity knobs):** Premature
convergence was visible in the fitness curve (best = median by
~gen 10, then flat). Added three cheap diversity levers behind one
"Maintain diversity" toggle. Defaults preserve behaviour byte-for-
byte; the airgap is untouched (search-internal).
- **`run_gp_v2` gains `immigrant_fraction: float = 0.0`.** When > 0,
`round(immigrant_fraction * population_size)` slots in the new
generation are filled with fresh programs drawn from
`ramped_population` (same rng, same grammar/objective/depth
constraints as init), inserted AFTER the elites and BEFORE the
crossover/mutation offspring β€” never displacing elites. Default
0.0 β‡’ no immigrants β‡’ behaviour unchanged. `tournament_k` and
`p_mutate` were already parameters; raising mutation and
lowering selection pressure are paired with immigrants via the
same toggle.
- **Pipeline + worker plumbing.** `run_v2_pipeline` and
`run_v2_pipeline_streaming` accept the new
`immigrant_fraction: float = 0.0`. `RunRequest` gains
`diversity: bool = False`. `Run` dataclass gains a `diversity`
field; surfaced in `/runs/{id}`. `_worker` maps `diversity=True`
β†’ `tournament_k=2`, `p_mutate=0.85`, `immigrant_fraction=0.10`;
`False` β†’ current `(3, 0.7, 0.0)`. Two new tests in
`tests/test_engine_v2.py`:
`test_default_run_unchanged_by_diversity_param` (passing
`immigrant_fraction=0.0` explicitly produces an identical best-
fitness trajectory to omitting the arg) and
`test_immigrant_fraction_injects_fresh_programs` (with `frac>0`
the last generation contains β‰₯1 immigrant β€” empty-parents and
non-survived β€” and more distinct `program_repr` strings than the
baseline).
- **Frontend.** New `<TIPS.diversity>` copy. ParamsControls gets
a "Maintain diversity" checkbox under the coherence toggle;
plain-English caption: *"Default off. Lowers selection pressure
and injects fresh random programs each generation so the
population keeps exploring β€” watch the best-vs-median gap in
the fitness curve stay open longer."* Lab state +
`postRun(body.diversity)` plumbed end-to-end.
- **Verification.** `pytest -q` 118/118 (was 116; +2 diversity
tests). `tsc --noEmit` clean. Airgap suite untouched. Live
HNSC HPV seed-11 coherence-on, 20 generations, A/B:
- **OFF**: gap collapses 0.242 β†’ 0.002 by gen 19 (full
convergence). Held-out 0.977, p 0.048.
- **ON**: gap stays 0.13-0.18 the entire run (mean 0.149 vs
OFF 0.041; last-gen 0.139 vs OFF 0.002). Held-out 0.948,
p 0.048 β€” detection preserved while the population stays
exploratory.
- **Chunk 7 follow-up (done β€” merge "GP's top programs" +
"Coordinated modules" into one sortable "Groups the engine
explored" table):** Replaced the two overlapping panels with a
single table over the SAME groups (harvested from the persisted
population), selectable by four sort lenses. One additive API
field, presentation refactor; airgap unchanged.
- **API.** `_compute_module_ranking` now records the max GP fitness
per gene-set while harvesting candidates and emits
`gp_fitness: float | None` on each module dict. Fitness is a
number β€” no gene names cross the wire. `RankedModule` in
`web/lib/api.ts` gains `gp_fitness?: number | null`.
- **Frontend β€” merged table.** `<ModuleRankingPanel>` retitled
**"Groups the engine explored"**. `SortKey` extends to
`"gp_fitness" | "combined" | "coherence" | "synergy"`. Default
sort = **gp_fitness** so the default view IS the engine's own
preference order (replacing the deleted top-programs panel).
Two new columns: **GP fitness** (per-row) and **Synergy** =
`combined_holdout βˆ’ max(single_gene_metric over per_gene)` β€” high
Synergy = real additive lift; low/negative = "best gene +
passengers" pattern. Sort buttons relabelled accordingly.
Subtitle rewritten in plain English (verbatim per prompt);
`TIPS.moduleRanking` rewritten to explain all four lenses + keep
the winner's-curse caveat for the re-score lenses.
- **Frontend β€” drop TopProgramsBlock.** `<ResultPanel>` no longer
fetches `getRunStatus` / `getRunPopulation` / `postReveal` /
`getFullRankDiagnostic` / `getRunModules`; the entire
`TopProgramsBlock` function definition and its supporting state
are gone. Result-panel signature simplifies to
`{ result, dataset, target, runId, coherence }` with `dataset` /
`runId` only used to gate the new pointer copy under the metric
cards: *"The winning program is badged in **Groups the engine
explored** below β€” sort by GP fitness to see the engine's full
preference order, or by Combined AUROC / Coherence / Synergy to
re-score the same groups by a different lens."* Pointer is gated
to `target !== "none" && coherence` (the same gate the merged
table uses). Unused imports (`Candidate`, `getRunStatus`,
`getRunPopulation`) removed.
- **Verification.** `pytest -q` 118/118 (no Python tests changed β€”
the new field is additive, the old payload tests still pass).
`tsc --noEmit` clean. Airgap suite untouched. Live HNSC seed-11
coherence-on confirms the four lenses give visibly distinct
orderings on the same module set: GP fitness #1 = winner
(RNF32+PCBD2+RPS10P7, gp 0.924); Combined-AUROC #1 = 11-gene
cell-cycle module (combined 0.978); Coherence #1 = a tightly
co-expressed 2-gene set; Synergy #1 = a 3-gene group with
+0.154 lift above its best single gene.
- **Chunk 7 follow-up (done β€” "GP fitness Γ— Synergy" landscape
scatter):** Added a canvas scatter above the merged Groups table
so the user can SEE which quadrant each explored group falls into
at a glance. Frontend-only; no engine / API / airgap change.
- **`<FitnessSynergyScatter>`** (~340 LOC in `web/app/Lab.tsx`).
x = `m.gp_fitness`, y = `synergyOf(m)` (lifted to a module-scope
helper so the table + scatter share one definition).
Categorisation via `categoriseModule(m, winnerSet)` β†’
`winner | p16 | immune | cell_cycle | MMR | other` (single-
marker sets win over broader sets; winner takes priority over
membership so the ring is always on top).
- **Canvas for the dots, SVG overlay for the chrome.** ~6k modules
in larger runs would choke as SVG nodes; canvas paints faint
grey background dots first, then amber/gold highlighted dots,
then the winner as a teal-ringed filled centre on top so it
never gets occluded. HiDPI: `canvas.width = clientWidth Γ— dpr`
+ `ctx.setTransform(dpr, 0, 0, dpr, 0, 0)`. Plot rect responsive
via a `ResizeObserver` on the wrapper div.
- **Quadrant story.** Vertical guide at the median GP fitness,
horizontal guide at synergy = 0 (orange dashed). Four captions
in the corners: "real teamwork (unexplored by the engine)" /
"ideal β€” usually empty (engine + teamwork)" / "weak / junk" /
"engine's lone-gene detectors". Axis titles: *"GP fitness β€” what
the engine preferred β†’"* and *"Synergy β€” teamwork beyond best
gene ↑"*. Dataset-aware legend (HNSC shows p16 + cell_cycle;
CRC shows immune + MMR).
- **Gated** to `target !== "none"` + coherence-on (same gate as
the survival flags / synergy column). Mounted between the
survival-flag caption and the Groups table inside
`<ModuleRankingPanel>` so it shares one SectionCard frame with
the table.
- **Verification.** `tsc --noEmit` clean; `pytest -q` 118/118 (no
Python changes). Live HNSC seed-11 coherence-on: 303 plottable
modules; winner @ `gp=0.924 / syn=0.032`; 21 dots in the
bottom-right "engine's lone-gene detectors" quadrant; 20 in the
top-left "real teamwork" quadrant the engine didn't prefer;
3 cell_cycle-flagged dots cluster at low GP (~0.58, the engine
barely scored them) with slightly-negative synergy β€” exactly
the "best gene + passengers" pattern the scatter is meant to
expose. Airgap untouched (synergy + category derived from
fields already on the wire; no extra reveal).
- **Chunk 7 follow-up (done β€” hover tooltips on the scatter,
highlighted dots only):** Added hit-tested tooltips to the
fitness Γ— synergy scatter so the user can read the gene-set and
scores of any highlighted dot without scrolling the table.
Frontend-only; airgap intact.
- **Hit-test scope.** When painting, `<FitnessSynergyScatter>`
now records the screen position of every HIGHLIGHTED dot
(`winner` + `cell_cycle` + `MMR` + `p16` + `immune`) into a
`hitsRef.current: Hit[]` array. The faint grey background dots
are deliberately NOT in this array β€” they are not revealed and
are not hit-tested. Selection: `mousemove` walks the array and
picks the nearest hit within ~8px (radiusΒ² = 64); `mouseleave`
clears.
- **Bounded reveal.** On mount / when `modules` changes, the
scatter takes the UNION of all highlighted modules' `gene_ids`
and issues ONE batched `postReveal` call β€” never the whole map.
On live HNSC seed-11 coherence-on: **31 unique opaque IDs**
revealed out of ~20k (winner 3 + 21 cell_cycle-tagged modules,
plus a handful of overlapping genes). Cached in
`highlightSymbols: Record<string, string>` so re-renders are
free.
- **`<ScatterTooltip>`** absolutely-positioned over the chart
container. Renders: source chip (winner / p16 / cell_cycle /
immune / MMR β€” coloured to match the dot), `n genes`, the
revealed gene symbols (falls back to `(revealing…)` while the
bounded call is in flight), and a 2Γ—4 grid of GP fitness,
Combined AUROC, Coherence, Synergy via `fmtFit` (non-finite β†’
em-dash). Synergy uses the shared `synergyOf` helper so the
tooltip, the table column, and the y-axis stay consistent.
Position clamps to the chart rect so dots near the edge don't
push the box off-screen.
- **Verification.** `tsc --noEmit` clean; `pytest -q` 118/118 (no
Python changes). Live HNSC seed-11 coherence-on: hovering the
winner ring shows `RNF32, PCBD2, RPS10P7 Β· GP 0.924 Β· Combined
0.915 Β· Coherence 0.474 Β· Synergy 0.032`; hovering a
cell_cycle dot shows e.g. `CDC6, …` with its scores. Background
dots are not hover-targets, no full-map reveal.
- **Chunk 7 follow-up (done β€” encode site survival on the scatter +
plain-language Survives explainer):** Two presentation fixes
around the confound flags. Frontend-only; the data
(`survives_site` / `survives_purity` / subgroup AUROCs) is already
on the wire.
- **Scatter site-survival encoding.** Highlighted dots in
`<FitnessSynergyScatter>` are now drawn:
- **solid** (fill + stroke) when `survives_site === true` (or
`null` / "n too small" β€” keeps the simple default visible),
- **hollow** (white fill + coloured stroke) when
`survives_site === false` β€” so failed-site groups read as
"less trustworthy" at a glance.
Only **site** is encoded; purity is mostly `null` on this
cohort and carries no signal. The category colour (winner teal
/ cell_cycle amber / p16 gold) is preserved. The winner ring
uses the same encoding when it fails the check.
- **Hide-failed toggle.** Added a small "Hide groups that fail
the site check" checkbox in the scatter header (default OFF).
On: `survives_site === false` highlighted dots are dropped
from both the canvas paint AND the hit-test array (no
accidental hover tooltips on hidden dots). Background grey
dots are unaffected (not site-tested).
- **Caption + legend updated.** The italic caption gains
"Solid dots survive the site check; hollow dots fail it (their
signal is partly location)." The legend gains a "hollow = fails
site check" entry.
- **Plain-language "Survives" column ?.** `TIPS.moduleSurvival`
rewritten verbatim per the prompt:
> What "Survives" checks: whether a group still separates HPV
> when you take away a possible confounder β€” something that
> travels with HPV but isn't HPV biology.
>
> Site / Purity each get a one-paragraph plain-English
> explanation; βœ“ / βœ— / β€” get a one-line legend each.
The tip is now anchored on the **Survives column header**
(the `<th>`) where it's most discoverable. Per-chip pre→post
tooltips on the rows are unchanged.
- **Verification.** `tsc --noEmit` clean; `pytest -q` 118/118
(no Python changes). Live HNSC seed-11 coherence-on shows
234 of 303 modules survive site / 69 fail; the 4 highlighted
dots (winner + 3 cell_cycle) all survive on this seed, so the
encoding renders solid β€” the failed-dot path is exercised in
the general case where a tagged module collapses.
- **Chunk 7 follow-up (done β€” five small UI fixes: default cohort,
remove peel-off, fix two clipped widgets, swap biology figure):**
Presentation-only sweep over Lab; frontend-only. No engine / API /
airgap change.
1. **Default to Head & Neck + HPV detection.** `dataset` state in
Lab now defaults to `"hnsc"`, and `target` to
`DATASET_REGISTRY.hnsc.objectives[0].key` so first load lands
on the HPV demo of the workflow. Colorectal stays selectable;
only the default changed.
2. **Hide the Discovered axes panel.** `<DiscoveredAxes>` is now
gated to `target === "none"` (never true in the UI β€” the
unsupervised objective isn't a UI objective anymore). On
supervised runs the panel was re-finding the same signal and
showing nonsensical "Aligns with MSI / TMB" text. The component
definition + backend chain are untouched in case a developer
flag wants to surface it again.
3. **Fix the "(TCGA COADREAD" subtitle clipping.** Not a CSS
issue β€” the old regex `/^[(]|[)]$/g.replace` was stripping the
trailing `)`. Replaced with
`entry.longLabel.replace(new RegExp('^${entry.label}\\s*'), '').trim()`
so the parenthesised tail renders intact, e.g. `(TCGA HNSC)`.
4. **"Survives" `?` is a click-to-open modal now.** Multi-paragraph
copy didn't fit a hover tooltip β€” clipped on the right.
`paramHelpContent.tsx::ParamKey` extended with `"module_survival"`
and a new `PARAM_HELP.module_survival` entry carrying the
prompt's verbatim site / purity / βœ“ / βœ— / β€” explainer. The
`<th>Survives</th>` (and the table-header caption above the
table) now uses `<ParamHelp paramKey="module_survival">` instead
of `<InfoTip>`. Per-chip `full β†’ subgroup` row tooltips are
unchanged.
5. **HPVBiologyPanel β€” new card-wall figures.** `HPVBiologyPanel.tsx`
now renders TWO inlined SVGs: `<NormalCellDiagram>` (the new
repo-root `normal_cell_reference.svg`, viewBox 920Γ—900: RB1 /
p16 / RB-E2F cycle / E2F / genes E2F turns on / protein
production / p53) followed by `<HPVDiagram>` (the rewritten
repo-root `hpv_reference.svg`, viewBox 920Γ—660: HOW HPV TAKES
OVER β†’ HOW E7 ATTACKS RB Β· HOW E6 ATTACKS p53 β†’ p16 BECOMES
THE HPV+ MARKER Β· WHY HPV DOESN'T MUTATE THE GENES). The
normal-cell baseline renders first so the reader sees the brake
before they see it dismantled. SVG attributes hand-translated to
JSX exactly as the existing component did (`font-family β†’
fontFamily`, `font-weight β†’ fontWeight={n}`, `text-anchor β†’
textAnchor`, `marker-end β†’ markerEnd`, etc.); each `<svg>` keeps
`viewBox` + a width:100% / height:auto wrapper so it scales
without overflow.
- **Verification.** `tsc --noEmit` clean; `pytest -q` 118/118 (no
Python changes). Lab first-load lands on HNSC + HPV; biology
panel shows the new normal-cell + HPV figures (both fully
visible, no overflow); the Discovered-axes panel is gone for
HPV / MSI / TMB runs; cancer-card subtitles read "(TCGA HNSC)"
with the closing paren; the Survives "?" now opens a click-
modal with the four-paragraph site / purity explainer that
never clips.
- **Chunk 7 follow-up (done β€” raised caps, "GP" β†’ "genetic
programming", DSL operator-usage):** Three changes across the
Lab + API surface.
- **Generations / Population caps raised.** Lab's NumField config
bumps Generations max 100 β†’ 1000 and Population max 500 β†’ 3000;
server-side `RunParamsModel.generations / population` Field
bounds raised to match. Bounded β€” not removed β€” so an
accidental huge value still can't hang the backend.
- **"GP" spelled out everywhere user-facing.** Every visible
occurrence in `Lab.tsx` replaced: "GP knobs" β†’ "Genetic-
programming knobs"; "Starts a GP run" β†’ "Starts a genetic-
programming run"; the compact label "GP fitness" β†’ **"Genetic-
programming fitness"** at the sort button, table header,
scatter heading + axis title + median guide + tooltip dt
+ module-ranking subtitle, and TIPS prose. The internal data
key `gp_fitness` is UNCHANGED (API contract). Code comments
still say "GP" by choice β€” they're not user-facing.
- **DSL operator-usage endpoint + tiles.**
- New `GET /runs/{run_id}/operator-usage` walks every candidate
in every persisted generation, counts each operator token
(`Select(`, `Reduce(`, `Combine(`, `Split(`, `Associate(`,
`Effect(`, `FitApply(`, `Search(`) in the `program_repr`
strings, and emits per-operator
`{name, total_uses, programs_using}` + run totals
(`n_generations`, `n_candidates`). Cached per run. 425 while
running, 404 unknown, 500 on error. Opaque-safe by
construction β€” operator keywords + integer counts only, no
gene IDs / symbols ever in the payload. Three new airgap
tests (`test_operator_usage_endpoint_returns_opaque_counts`,
`_425_while_running`, `_404_for_unknown_run`).
- Frontend: `getRunOperatorUsage(runId)` fires from the SSE
`done` handler; Lab caches the result in `operatorUsage`
state. New `<DSLVocabularyTiles>` driver swaps each
`<DSLVerbCard>` for an enhanced version that adds a
`used NΓ— β€” in P% of programs` line and a tile-relative bar
(height 1, accent fill, white-grey track) under the
description. Before / during a run, tiles render just the
hints (no counts). A caption appears beneath the grid after
a completed run.
- **Verification.** `pytest -q` 121/121 (+3 new airgap tests).
`tsc --noEmit` clean. Live HNSC seed-11 coherence-on, 10Γ—80:
Reduce in 100% of programs (grammar floor), Select 90.6%
(1098 uses), Combine 21.2% (216), Split 4.1%, Associate 2.4%,
Effect 1.0%, Fit/Apply 3.6%, **Search 0** (off by default).
Airgap-clean payload β€” keywords + counts only.
- **Chunk 7 follow-up (done β€” turn Search on by default + add a
per-run gate):** Search was the one DSL operator still gated off
(`DEFAULT_RATES["search"] = 0.0`). Turn it on at a modest rate so
the engine can use the full DSL, and add a UI toggle so it can be
flipped off when speed matters (every Search node runs an inner
gene ranking).
- **Engine.** `engine_v2/synthesize.DEFAULT_RATES["search"]` flips
0.0 β†’ **0.05**. Caps stay (`SEARCH_MAX_K`, ≀ 200 candidate
columns) so each Search's cost is bounded. The existing
`is_unsup` gate at the injection site still forbids Search on
unsupervised runs.
- **`run_gp_v2` gains `rates_override: dict | None = None`.** Inside
`run_gp_v2` the override is merged on top of the objective's
synthesis-overrides (`UNSUP` etc.) β€” caller's keys win β€” and the
merged dict is threaded into every `ramped_population` / `mutate`
call. Both pipeline entry points (`run_v2_pipeline`,
`run_v2_pipeline_streaming`) expose `rates_override` and forward
it through.
- **API.** `RunRequest` gains `enable_search: bool = True`. `Run`
dataclass tracks it; `/runs/{id}` surfaces it. `_worker` builds
`rates_override = None` when on, or `{**DEFAULT_RATES,
"search": 0.0}` when off, and passes it into the pipeline. The
override is a complete dict (overlaid on DEFAULT_RATES) so the
inner `synthesize._grow_*` code can index by key without a
fallback.
- **Frontend.** New `<TIPS.enableSearch>` copy. ParamsControls
gains an **"Enable Search operator"** checkbox under the
diversity toggle (default ON). Lab state + `postRun.body
.enable_search` plumbed end-to-end. DSL-vocabulary tile for
Search now reads *"Nested feature search β€” runs a small
gene-ranking inside the program (bounded: ≀ 4 genes from
≀ 200 columns)."* β€” the "off by default" wording is gone.
- **Tests.** `tests/test_api_airgap.py::test_enable_search_false_pins_search_to_zero`
verifies the toggle: with `enable_search=False` the operator-
usage payload reports Search at 0 total uses / 0 programs, and
`/runs/{id}` surfaces the flag. The existing
`test_operator_usage_endpoint_returns_opaque_counts` relaxed
its old "Search must be 0" assertion (it's now `>= 0` β€” Search
may or may not fire on a tiny synthetic 3Γ—12 run).
- **Verification.** `pytest -q` 122/122 (+1). `tsc --noEmit`
clean. Live HNSC seed-11 coherence-on, 10 Γ— 80:
- **Search ON** (default): Search used **20Γ—** across 20 / 800
programs (~2.5%); winner AUROC 0.909, p 0.048.
- **Search OFF**: Search used **0Γ—** / 0 programs; winner
AUROC 0.941, p 0.048.
- **Chunk 7 follow-up (done β€” editable DSL rates in the vocabulary
panel + drop the Search checkbox + reorder DSL before Run):**
Make every optional DSL operator's injection rate editable in the
DSL vocabulary section (pre-filled with engine defaults), fold the
Search checkbox into a Search-rate input (0 = off), and move the
panel above the Run section. Airgap untouched.
- **API.** `RunRequest.enable_search` is gone; replaced by a flat
`RunRequest.rates_override: dict[str, float] | None`. Keys we
accept: `split`, `effect`, `fitapply`, `search`, `scalar_share`.
Missing keys keep their engine defaults β€” an empty / missing
override reproduces current behaviour byte-for-byte. `Run`
dataclass stores the dict; `/runs/{id}` surfaces it.
`_worker` splits the flat dict back into `rates_override`
(overlay on `engine_v2.synthesize.DEFAULT_RATES`) and
`scalar_share_override`, then forwards both to the pipeline.
- **Engine.** `run_gp_v2` gains
`scalar_share_override: float | None = None`; merged with the
objective's overrides via an `effective_scalar_share` that wins
over `overrides.get("scalar_share")`. Both pipeline entry
points expose the new param and forward it.
- **Frontend.** New `DSL_DEFAULT_RATES` constant mirrors the
engine defaults (Split 0.10 / Effect 0.40 / Fit/Apply 0.10 /
Search 0.05 / scalar_share 0.20). `<DSLVocabularyTiles>`
rewritten: structural operators (Select / Reduce / Combine /
Associate) render with an "always available" tag; optional
operators (Split / Effect / Fit/Apply / Search) render with a
small `<input type=number min=0 max=1 step=0.05>` pre-filled
with their default. A separate `scalar_share` field sits in a
footer row alongside two buttons: **Reset to defaults** (off
when already at defaults) and **Set all equal** (one shared
value for the four optional operators β€” the neutral-prior
experiment). The `<TIPS.dslRates>` `?` explains the inputs.
- **Wire shape.** The Lab only sends keys the user actually
changed (`ratesDiff`), so the default workflow keeps producing
an empty / absent `rates_override` field and behaviour is
byte-for-byte unchanged.
- **Layout.** The DSL vocabulary `<SectionCard>` moved ABOVE
`<RunBar>` / `<LiveView>` so the (now interactive) config sits
before the Run button. Order is now Cancer β†’ Objective β†’
Biology β†’ Parameters β†’ DSL vocabulary β†’ Run β†’ Live view.
- **ParamsControls.** The "Enable Search operator" checkbox and
`enableSearch` state/prop are gone; that toggle lives as a
rate input on the Search tile (rate 0 = off).
- **Tests.** `tests/test_api_airgap.py::test_enable_search_false…`
renamed to `…rates_override_search_zero…` and rewritten to
POST `{"rates_override":{"search":0.0}}`; asserts the
operator-usage payload reports Search at 0 total / 0 programs
AND `/runs/{id}.rates_override == {"search": 0.0}`. The
`OperatorUsage` opaque-counts test stays the same. 122 / 122
pytest pass; airgap suite green.
- **Verification.** `tsc --noEmit` clean. Live HNSC seed-11
coherence-on, 10 Γ— 80:
- Default (no override): Search 20Γ—, Split 25, Effect 6,
Fit/Apply 27 (the current DEFAULT_RATES mix).
- `rates_override={"search":0.2}`: Search **40Γ—** (double the
default rate β‡’ roughly double the uses); Split, Combine etc.
shift because raising Search's slot draws from the same
Matrix-slot RNG mix.
- `rates_override={"search":0.0}`: Search **0Γ—** exactly,
matches the old `enable_search: false` behaviour.
- `/runs/{id}` surfaces `rates_override: {"search": 0.2}` for
the UI.
- **Chunk 7 follow-up (done β€” DSL panel polish: scalar-share copy,
rate explainer, collision-free lollipop labels):** Three small
presentation fixes in `web/app/Lab.tsx`. No engine / API / airgap
change; `tsc` clean.
- **Scalar share copy rewritten in plain English.** The native
`title=` jargon on the scalar-share row is gone. Inline
description: *"how many starting programs are built around
Associate / Effect (which score a gene set by how well it
correlates with the target) instead of giving each patient a
score. 0.20 β‰ˆ 1 in 5; 0 = never."* New per-field `<InfoTip>`:
*"Every program ends in one of two outputs: a score for each
patient (the usual case), or a single correlation number
produced only by the Associate or Effect operators. Scalar
share is the fraction of starting programs built around that
second kind…"* (`"(default 0.20)"` hint, the input, and the
Reset / Set-all-equal buttons unchanged.)
- **Rate explainer added to the DSL panel.** `TIPS.dslVocabulary`
(the panel `?` next to the section title) replaced with the
verbatim "Rate = how often the engine reaches for an operator
…" copy β€” covers the Effect/Associate carve-out and the "not a
measure of quality" caveat. Each tile's "rate" label gains an
`<InfoTip>` with the short version (per-operator default
interpolated), so the meaning of the input is discoverable
without leaving the tile.
- **Lollipop chart: collision-free label placement.** Previous
fixed 4-row stagger garbled tight clusters (MCM2..7 / AURKB /
CCNB1 rendered as overlapping strings). Replaced with a greedy
placement: sort by x, estimate each label's pixel width
(β‰ˆ `text.length Γ— fontSize Γ— 0.6`), drop into the lowest row
whose previous label's right edge + 6px ≀ this label's left
edge; open a new row if none fits. **No cap on rows.** The
chart `H` is now derived from `numRows` so the SVG grows to
fit and nothing clips. Per-label leader lines reach down to
the dot from however many rows above sit the label. The MCM
cluster now reads cleanly.
- **Chunk 7 follow-up (done β€” show each group's best program tree
on row expand):** Each row's Genetic-programming fitness is the
max fitness among evolved programs with that gene-set; now the
argmax program's tree is carried through and rendered on expand.
Airgap untouched.
- **API.** `api/app.py::_compute_module_ranking` walks the
population once and now tracks BOTH the per-set max
`gp_fitness` AND the corresponding `program_repr` in a sibling
dict (`best_program_repr_by_key`). Each module payload emits
`best_program_repr: str | None`. Opaque-safe β€” `program_repr`
is built from opaque IDs only.
- **Types.** `RankedModule.best_program_repr?: string | null` in
`web/lib/api.ts`.
- **Frontend.** New `<ModuleBestProgram>` rendered inside the
Groups-table expanded row, just under `<ModuleGeneTable>`.
Caption: *"This group's best program β€” The actual tree of the
candidate that earned the Genetic-programming fitness above
(argmax over the persisted population for this gene-set)."*
Uses the SHARED `<ProgramGraph>` (the same component the
Result panel uses for the winner), with the row's already-
revealed `symbolByOpaque` adapted to ProgramGraph's
`Record<id, {symbol, matched}>` shape (matched=false β€” the
known-marker highlight is a separate concern from per-group
trees). The raw `program_repr` + a `<CopyButton>` sit
underneath, so the string can be read or copied. Output label
switches by target (HPV+ / MSI-H probability, TMB association,
or score).
- **Verification.** `tsc --noEmit` clean; `pytest -q` 122/122;
airgap suite green. Live HNSC seed-11 coherence-on: 297/297
modules carry `best_program_repr`. The winner's row's
`best_program_repr` matches the Result-panel `winning
.program_repr` exactly (`Reduce(Select(M,[g12850,g18272]),
mean)` on this seed). Airgap scan of the modules payload
finds no gene-symbol leak.
- **Chunk 7 follow-up (done β€” HNSC copy rewrite for a non-specialist
reader):** Copy-only pass across the Head & Neck / HPV Lab so a
co-founder can scroll top-to-bottom and understand each section
without outside help. No engine / API / airgap / logic change.
- **Voice spec:** plain, gloss jargon on first use (AUROC = 0.5
coin-flip / 1.0 perfect; held-out = patients the engine never
trained on; permutation p = how often random noise matches this);
cut implementation plumbing ("Server-Sent Events", "on the
backend", "opaque IDs", "airgapped", "residualise") while KEEPING
the credibility ideas (blind discovery, held-out honesty,
beats-chance, reveal-only-at-end) in plain words.
- **Section-by-section rewrites** (in `web/app/Lab.tsx` + the
biology panel + `paramHelpContent.tsx`): header subtitle (engine
hunts blind, gene names hidden as codes); Cancer/problem
subtitle; Objective subtitle; `obj_hpv` modal rewritten to the
verbatim HPV-detection intro + AUROC gloss + "detecting a known
viral fingerprint" honest note; HPV-biology caption; Parameters
subtitle + every `PARAM_TIPS[*]`; DSL vocabulary subtitle +
every tile hint (Select = "Pick specific genes.", Reduce =
"Combine those genes into one score per patient (average, max,
etc.).", Combine = "Merge two scores into one.", …); Run
subtitle; Live view subtitle + `TIPS.bestVsMedian` verbatim
per prompt; y-axis label "separation (AUROC β€” 0.5 coin-flip,
1.0 perfect)"; Population subtitle; Program graph subtitle +
reveal-state phrasing; Result subtitle + HPV-specific held-out
/ permutation / nodes / genes tooltips; Known-marker recovery
subtitle + chart title; Groups the engine explored subtitle;
scatter heading/caption; operator-usage caption; Reveal &
evaluate subtitle. `<th>Opaque ID</th>` β†’ `<th>Gene code</th>`
across the three reveal tables; raw-ranking disclosure copy;
live-view chip label; `TIPS.opaqueIds` polished.
- **Jargon sweep clean.** No `Server-Sent Events`, `SSE`, `on the
backend`, `worker thread`, or `airgapped` in visible copy.
- `tsc --noEmit` clean; no behaviour change.
- **Chunk 7 follow-up (done β€” external-cohort transfer test:
GSE65858 end-to-end):** Add a second, INDEPENDENT HPV validation
cohort as a reveal-side transfer test β€” score the HNSC/HPV winner,
discovered blind on TCGA, on ~270 GEO head & neck tumours from a
different country, hospital, and measuring machine. Purely additive
β€” the engine still discovers blind on TCGA; GSE65858 lives on the
NAMED (reveal) side of the airgap and only ever sees the winner's
already-revealed symbols.
- **Data pipeline.** `data_pipeline/schema.py` gained a GSE65858
block (series-matrix URL, GPL10558 platform URL, raw/processed
dirs, HPV label constants). `data_pipeline/download_gse65858.py`
mirrors `download_hnsc.py` (fetch series matrix + platform
annot from NCBI FTP with loud manual-fallback instructions;
live-download works β€” 21 MB series matrix + 7 MB platform).
`data_pipeline/build_gse65858.py` parses the GEO series-matrix
format, derives the STRICT virus-active `HPV+` (DNA+RNA+) label
from the joint `hpv16_dna_rna` characteristic with a fallback
ladder (joint β†’ separate DNA/RNA β†’ single status field β†’ single-
side calls), maps probes β†’ HUGO symbols via GPL10558, collapses
to a symbol Γ— sample matrix (mean over probes), writes
`data/processed_gse65858/{clinical,expression}.parquet`, and
prints a full provenance report. **Live build: 250 called (35
HPV+ / 215 HPVβˆ’) across 16,951 symbols** β€” matches the paper's
strict virus-active rate.
- **Named-side transfer function** (`validate/transfer_gse65858.py`)
β€” a pure, testable `transfer_score(symbols, *, n_permutations,
seed, processed_dir)` returning `{auroc, p, n, n_pos, n_neg,
n_found, n_missing, found_symbols, missing_symbols}`. Cross-
platform fix: **z-score each found gene within GSE65858** before
the per-patient mean (TCGA RNA-seq scale vs Illumina array
intensity). Orientation-agnostic AUROC + permutation-null p (with
`+1/+1` smoothing). Finite-guarded; graceful `null` payload when
`n_found == 0`. 5 new tests in `tests/test_transfer_gse65858.py`
(signal β†’ high AUROC / small p; noise β†’ chance; missing symbols
reported; no-found graceful; payload carries only supplied
symbols).
- **API endpoint** β€” `GET /runs/{run_id}/transfer`. Gated to
**HNSC + HPV** runs only (400 otherwise); 425 mid-run, 404
unknown, 503 if the cohort parquets aren't built. Bounded reveal
of the winner's opaque IDs via `airgap.reveal` (same discipline
`/evaluate` uses). Lazy-imports `validate.transfer_gse65858`.
Cached per-run. Payload: `{cohort, platform, source, n_cohort,
auroc, p, n, n_pos, n_neg, n_found, n_missing, found_symbols,
missing_symbols}`; the only gene NAMES are the winner's own
revealed symbols. 3 new airgap tests (non-HPV reject; payload
carries only winner symbols with whole-word regex β€” so
`AARS` inside `AARSD1` doesn't false-positive; 425 mid-run).
- **Frontend.** `TransferResult` type + `getRunTransfer(runId)`
fetcher. New `<ExternalValidation>` component at the end of
`<ResultPanel>`, gated to `dataset === "hnsc" && target === "hpv"
&& runId`. Fetches on mount. Renders: section header + `?` +
subtitle; green/amber verdict callout ("It holds on strangers."
vs "Not confirmed on strangers.") gated on `auroc β‰₯ 0.75 && p <
0.05`; three metric cards (Independent AUROC Β· Permutation p Β·
Genes measurable `n_found / (n_found+n_missing)`) each with its
own plain `?`; `<Metric>` gained a `sub` prop for the subtitle
line under the value; amber cross-platform pill (RNA-seq β†’
microarray); three-step "How this validation works" strip
(reveal β†’ match & level β†’ score) in light teal; teal πŸ”’ airgap
footnote; one-liner honest note. Graceful fallback when the
cohort isn't built (503 β†’ build-hint instead of crash).
- **Verification.** `pytest -q` 130/130 (+8 new tests; +5 transfer
+ 3 airgap). Airgap suite green. `tsc --noEmit` clean. Live
end-to-end: HNSC HPV seed-11 winner `[g12850, g18272]` β†’
symbols `PCBD2, TMEM71` β†’ transfer on GSE65858 **AUROC 0.799,
p 0.001 across 250 patients (35 HPV+ / 215 HPVβˆ’)**. Payload
contains ONLY the two winner symbols. Textbook markers on
GSE65858: **CDKN2A alone β†’ AUROC 0.896, p 0.002**; MCM cluster
β†’ 0.85; TP53 β†’ 0.74.
- **Chunk 7 follow-up (done β€” `scripts/multiseed_hpv.py` multi-seed
stability orchestrator):** Read-only biology-aware script that runs
the HNSC/HPV `engine_v2` pipeline across a default seed grid
`[1, 3, 7, 11, 13, 17, 23, 29]` and reports per-seed held-out
AUROC + permutation p + winner gene_ids, transfer AUROC + p +
n_found/n_missing on GSE65858, held-out and transfer AUROC ranges
across seeds, and a gene-recurrence tally (each symbol tagged by
its HNSC reference set β€” p16 / cell_cycle β€” or `-`). Reveal is
bounded per seed to that seed's own winner's opaque IDs via
`airgap.reveal` (`scripts/` is allowed to be biology-aware; same
discipline `/evaluate` uses); the sealed map is never dumped;
GSE65858's gene list never crosses back into the engine. CLI:
`python -m scripts.multiseed_hpv --seeds 1 3 7 --generations 30
--population 300`.
- **Chunk 7 follow-up (done β€” "Stability across seeds" panel in the
Lab):** Frontend-only multi-seed stability panel, gated to HNSC/HPV,
appended after `<ExternalValidation>` in `<ResultPanel>`. Reuses
the existing run, transfer, reveal, and full-rank endpoints β€” no
engine / API / airgap change.
- **State.** Own `stabilityRows` / `stabilityStatus` / `stability
Progress` / `stabilityCancel` β€” the main single-run Result / Live
view stays untouched during a sweep. A dedicated
`awaitRunDone(runId)` helper opens an `EventSource` and
resolves on the `done` event without calling `setResult` /
`setGenerations` / `setStatus`.
- **Body composition.** Per-seed body mirrors `launchRun()`
exactly: `postRun({ objective_spec: OBJECTIVE_PRESETS.hpv,
params: { ...params, seed }, engine: "v2", dataset, coherence,
diversity, rates_override: ratesDiff })`. Only DSL-rate keys
that differ from defaults travel on the wire so a default sweep
is byte-for-byte identical to the current run.
- **Sequential** (backend runs one at a time), with a `cancelRef`
between seeds so a "Stop after this seed" button halts cleanly.
Progress readout "Running seed k of N…" during the sweep.
- **Rendering β€” three sections + summary callout:**
1. **`<StabilitySummaryCallout>`** β€” deterministic, LLM-free,
composed from the aggregates in JS. Rendered above the dot-
strips only when `stabilityStatus === "done"`. Green if
`perfStable && geneStable` (held-out min β‰₯ 0.75, every GP p <
0.05, and top-recurring gene in β‰₯ ⌈N/2βŒ‰ seeds); teal /
informative if `perfStable && !geneStable`; amber otherwise.
Copy verbatim per prompt: Line 1 performance ("Across N
independent searches, the engine detected HPV every time…"
with an "every run beat chance" or "most runs beat chance on
the independent cohort" tail); Line 2 gene story ("The same
genes kept coming back β€” TOP in K/N runs…" vs "the specific
genes differed almost every run β€” the most repeated was TOP
(K/N), and M genes appeared only once…"); Line 3 honest close
only when `!geneStable` ("So trust the detection, but don't
read any single run's gene list as THE gene list…"). Extra
guard: when no gene appears in more than one seed
(`topGene == null`), the copy switches to "no single gene
showed up in more than one seed's winner…" rather than saying
"the most repeated was null".
2. **"Does the result hold across seeds?"** β€” two horizontal
dot-strips on a 0.5 β†’ 1.0 axis (`0.5 coin-flip / 0.75 / 1.00`
tick labels). Accent-teal dots for Held-out AUROC (from
`winning.holdout_score` β€” same field the Result card shows,
so the stability dots match the numbers on screen), amber
dots for Independent AUROC. Light min-max band per strip;
header carries `min–max` via `fmtFit`.
3. **"Do the same genes keep coming back?"** β€” recurrence bars:
symbol Β· width-proportional bar Β· `K / N` Β· reference tag.
Recurring (β‰₯ 2 seeds) sorted desc; genes in only 1 seed
collapse into a single "N genes Β· 1 seed each" row with the
first 40 passenger symbols in the title tooltip. Colour:
cell_cycle amber, p16 gold, alternate teal, passengers grey.
4. **Per-seed table**: `seed | held-out AUROC | p | indep. AUROC
| indep. p | genes found / total`. Every number through
`fmtFit`; error seeds show `error` with the message on hover.
- **Reveal discipline** β€” per seed, `postReveal(winning.gene_ids)`
(bounded) sources the winner's symbols; then one
`getFullRankDiagnostic(dataset, target)` at the end for the
reference-set tag map (`p16` wins over `cell_cycle` when a
symbol is in both). No new reveal surface.
- `tsc --noEmit` clean. `pytest -q` unchanged (frontend-only).
Sanity-tested the data path via `TestClient`: 3 seeds Γ— tiny
budget produces 3 completed runs, each with `holdout_score`,
`permutation_p`, revealed symbols, and a live `/transfer`
payload; the recurrence tally rolls up correctly.
- **Chunk 7 follow-up (done β€” `scripts/capability_ratio_test.py`
known-answer capability test):** Self-contained blind capability
check. Plants a synthetic binary target defined by the balance
between two REAL, positively-correlated genes (a log-ratio
direction) β€” rigged so NEITHER gene helps on its own β€” and asks the
blind engine to rediscover the interaction. Isolated: ONE new file
under `scripts/` (allowed to be biology-aware); READS the existing
processed matrix + REUSES the sealed map via `airgap.anonymise` /
`airgap.reveal` for the final check; writes nothing to disk; adds
no dataset, no API route, no UI, no engine change. Deleting the
file leaves zero trace.
- **Pair search.** Smart seed-and-rank strategy (random-pair
sampling is too slow at r β‰₯ 0.85 on 20k genes): pick a random
seed gene from a variance/expression-filtered pool, rank the
rest by descending Pearson r against it, check the top
candidates. Constraints: `r ∈ [0.85, 0.99]`, single-gene AUROC
in `0.5 Β± 0.07`, ratio AUROC β‰₯ 0.9. Configurable via CLI
(`--pair`, `--min-r`, `--single-band`, `--ratio-min`,
`--pair-seed`).
- **Planted target.** `zA = zscore(A), zB = zscore(B); signal = zA
βˆ’ zB; y = (signal > median(signal))`.
- **Setup + panel controls (auditable).** Print single-gene AUROCs
(~0.5), ratio AUROC (~1.0), best single-gene AUROC over the panel
(~0.6), and panel-mean AUROC (~0.57) BEFORE running β€” so any
high held-out score MUST come from composition.
- **Blind runs.** `anonymise(panel_expr)` reuses the existing
sealed map (no re-seal). Per-seed `run_v2_pipeline` with
`V2Objective(target="msi", binary=True)` as the binary-AUROC
carrier, `prefilter_n=None`, `scalar_share_override=0.0` (force
Vector programs), `coherence_weight=0.0`, diversity on
(`tournament_k=2, p_mutate=0.85, immigrant_fraction=0.10`),
`population=200, generations=40, permutations=100`.
- **Grader.** Per seed: `reveal(winning.gene_ids)` (bounded).
PASS if revealed symbols βŠ‡ `{A, B}` AND `holdout_score β‰₯ 0.80`
AND the `program_repr` contains a `Combine(` with one of
`protected_div` / `mul` / `sub`. Prints program_repr, revealed
symbols, held-out AUROC, per-check verdicts, and the overall
"recovered in k / n seeds" line. PASS interpretation copy fires
at `k β‰₯ ⌈n/2βŒ‰`.
- **Live evidence** (seeds 1, 3, budget 200 Γ— 40): auto-picked
pair **FPR3 / C3AR1** (r 0.923). Seed 1 winner:
`Combine(Reduce(Select(M,[…,g06688,…]),median),
Reduce(Select(M,[g11569,…]),median), protected_div)` β€” the
planted ratio, held-out AUROC **0.858** vs the 0.60 best-single
control. **PASS.** Seed 3 finds FPR3 without C3AR1 (0.62,
FAIL). Overall 1/2 β†’ PASS: *"The engine can discover a genuine
two-gene interaction blind β€” so when a real target (HPV) yields
only averages, that's because the biology doesn't need a ratio,
not because the engine can't build one."* `pytest -q` still
130/130 (nothing outside `scripts/` was touched).
- **Chunk 7 follow-up (done β€” `validate/tmb_resid_rank.py` "leftover-
TMB" diagnostic):** Cheap read-only diagnostic checking whether
MSI-residualized TMB is a candidate COMBINATORIAL target on
colorectal. Mirrors `validate/tmb_rank.py` exactly (same cohort
loader shape, same NAMED matrix, reuses `_spearman_per_column`
verbatim); lives in `validate/` (structural airgap test scans
only `engine/`); sub-second on the live cohort. No engine / API /
UI / data change.
- **Pipeline.** `Load("processed")` filtered to `tmb.notna() &
msi_status ∈ {MSI-H, MSS} & no-NaN expression rows`. `t =
log1p(TMB)`, then WITHIN each MSI group separately z-score
`(t βˆ’ group_mean) / group_std` (guard `std > 0`) β€” pooled into
one residual vector aligned to `X.index`. Two `_spearman_per
_column` passes (raw baseline + residual headline), both ranked
by `|corr|` descending. Reports: `best_raw` vs `best_resid`
(drop tells you MSI's slice was removed), top-N by
`|Spearman(gene, resid)|`, MMR + IMMUNE gene positions against
the residual, per-group stats (`n`, TMB mean, log1p mean/std)
so the residualization is auditable.
- **Verdict.** Thresholds on `best_resid`: `< 0.30 β†’ CANDIDATE
COMBINATORIAL TARGET`, `β‰₯ 0.40 β†’ NOT COMBINATORIAL`, otherwise
`BORDERLINE`.
- **Live output** on the colorectal cohort: N = 355 samples,
20,056 genes; MSI-H n=50 (TMBΜ„ 49.8) Β· MSS n=305 (TMBΜ„ 7.6).
Best `|corr|` vs raw TMB = **0.447 (CXXC1)** β€” MSI-driven, as
expected. Best `|corr|` vs residualized TMB = **0.346
(FOXD4L1)** β€” drop **βˆ’0.10**. MMR genes now WEAK against the
residual: MLH1 rank 2543/20056, MSH2 at 17313, PMS2 at 15473
(sanity check that MSI's effect was removed). PRF1 the
strongest immune gene at rank 161 (`|corr| = 0.19`). Verdict:
**BORDERLINE** β€” *"A weak single-gene signal remains; a GP run
might still be informative."*
- **Chunk 7 follow-up (done β€” `scripts/tmb_resid_gp.py` synergy
check on MSI-residualized TMB):** Second isolated script that
answers whether the DSL finds gene COMBINATIONS beating the best
single gene on the "leftover TMB" target the diagnostic borderlined.
ONE new file under `scripts/` (biology-aware) + a small helper
refactor in `validate/tmb_resid_rank.py` to keep the residual
byte-identical between the diagnostic and the GP script.
- **`validate.tmb_resid_rank.build_residual_cohort()`** β€”
factored-out public helper returning `(X_named, residual_series,
group_stats)`. Both `main()` and `scripts/tmb_resid_gp.py` call
it, so any tweak to the within-MSI z-scored `log1p(TMB)` target
lives in ONE place. Existing diagnostic behaviour byte-for-byte
identical (verified by pytest 130/130).
- **`scripts/tmb_resid_gp.py`** β€” reads the existing processed
matrix; imports the residual via `build_residual_cohort()`;
`anonymise(X_named)` reuses the sealed map; runs
`run_v2_pipeline(M, y, objective=TMB_OBJECTIVE, seed, ...)` per
seed with `prefilter_n=None`, `scalar_share_override=0.0`
(Vector programs only), `coherence_weight=0.0`, diversity on
(`tournament_k=2, p_mutate=0.85, immigrant_fraction=0.10`);
default budget `--pop 300 --gens 50 --perms 200` on seeds
`[1, 7, 13]`. Per-seed grader reveals only the winner's opaque
IDs via `airgap.reveal` (bounded β€” same discipline as
`/evaluate`) and computes the SINGLE-GENE CEILING on the SAME
held-out test rows via `_test_ids_for(M, y, seed)` (mirrors the
pipeline's continuous split: `make_split(stratify=False,
random_state=seed)`) + `_spearman_per_column(X_test, y_test)`.
`synergy = combined βˆ’ ceiling`. Prints per-seed
program_repr / revealed genes / combined |spearman| (with
n_test) / ceiling / synergy / permutation p, ranges across
seeds, then an overall verdict:
- `median syn β‰₯ 0.10 && n_sig β‰₯ ⌈n/2βŒ‰ && median combined β‰₯ 0.30`
β†’ **REAL COMBINATORIAL SIGNAL**.
- `|median syn| < 0.05 && median combined < 0.50` β†’
**NO SYNERGY**.
- `median combined < 0.20 || range β‰₯ 0.20` β†’
**THE LEFTOVER IS LARGELY NOISE**.
- else **BORDERLINE**.
- **Airgap.** `engine_v2` sees only opaque IDs; symbols cross the
boundary once per seed via a bounded `reveal(winner_ids)` call.
No engine / API / UI / dataset change; writes nothing to disk;
deleting the script leaves zero trace.
- **Verified live.** Smoke `--seeds 1 --pop 60 --gens 10 --perms 20`
prints the full per-seed block + ranges + verdict; scipy's
`ConstantInputWarning` from degenerate programs (already floored
to `WORST_FITNESS` by the engine) is silenced at script scope.
`pytest -q` still 130/130.
- **Chunk 7 follow-up (done β€” Groups-the-engine-explored:
ascending/descending sort toggle):** Frontend-only. The Groups
table (`<ModuleRankingPanel>` in `web/app/Lab.tsx`) sorted
descending only; clicking the active sort key now flips direction,
clicking a new key resets to descending, and the active button's
label appends `↓` / `↑` so the current direction is legible.
Implementation: new `SortDir = "asc" | "desc"` state next to
`sortKey`; `sortedModules` refactored to a single `valueOf`
switch with null / non-finite modules PINNED to the bottom in
BOTH directions (so a missing metric never floats to the top on
ascending). Reset effect fires on `sortDir` too β€” flipping
direction resets page 0 and closes any expanded row. `tsc
--noEmit` clean; no API / airgap / engine change.
- **Chunk 7 follow-up (done β€” Groups-the-engine-explored: plain-
English copy pass on the panel + per-column "?" tooltips):**
Presentation-only. Two copy rewrites and four new column-header
tooltips in `<ModuleRankingPanel>` (`web/app/Lab.tsx`).
- Subtitle rewritten to the four-column story ("Every gene group
the engine tried, scored four ways so you can judge whole
groups, not just single genes") with a one-liner gloss per
column. Fixes a literal `&rsquo;` that was rendering in the
old subtitle; straight apostrophes only.
- `TIPS.moduleRanking` (the panel-title `?`) rewritten to lead
with "Why these columns exist" β€” the single-gene vs group
question the panel is meant to answer β€” followed by the
honest re-scoring caveat.
- Each of the four metric column headers (Genetic-programming
fitness · Combined AUROC / |ρ| · Coherence · Synergy) now
carries a small `<InfoTip>` that says how to READ that column
(high vs low), wrapped in
`<span className="inline-flex items-center justify-end gap-1">`
so it hugs the right edge with the text-right header. Combined
header's `metric_kind` conditional preserved inside the wrapper.
- `tsc --noEmit` clean; no API / airgap / engine change.
- **Next:** survival + unsupervised objectives end-to-end; bigger
default Lab budget for the full grammar; cross-cohort validation;
durable run store; mechanism-aware objective; lifting Search's
default rate once its cost profile is profiled.
## Layout
```
data_pipeline/ download + build for both datasets. CRC:
download.py + build.py β†’ data/processed/*.parquet
HNSC: download_hnsc.py + build_hnsc.py β†’
data/processed_hnsc/*.parquet (with hpv_status label)
app/ Streamlit viewer + presentation theme (theme.py)
.streamlit/ theme config.toml (light, Helvetica)
data/raw/ cBioPortal CRC files (gitignored)
data/raw_hnsc/ cBioPortal HNSC files (gitignored)
data/processed/ clinical.parquet, expression.parquet,
_sealed_gene_map.json,
h2/{evolution_log,result}.json (all gitignored)
data/processed_hnsc/ HNSC clinical.parquet (with hpv_status) +
expression.parquet (gitignored)
dsl/ DSL operators (Load, Select, Reduce, Split, Associate,
Effect, Search, Fit, Apply) β€” label-agnostic, biology-free
airgap/ anonymise / reveal + sealed symbol<->ID map
engine/ v1 GP engine (split, prefilter, program, fitness,
baseline, permutation, gp, pipeline) β€” strict airgap,
biology-free. Fixed 1–2 Select+Reduce sets fed into
LogisticRegression.
engine_v2/ v2 typed program synthesis (types, nodes, synthesize,
fitness, permutation, gp, pipeline) β€” strict airgap.
Full DSL grammar (Select/Reduce/Combine/Split/
Associate/Effect/FitApply/Search) over the opaque-ID
matrix; ExecContext carries named clinical fields
(stage, age) and label arrays (msi, tmb) only β€” no
gene names. Worst-score floor for degenerate
programs; winner-fixed permutation null. Search is
bounded + gated OFF by default.
api/ FastAPI app: legacy /health /run /result /reveal +
Lab endpoints /runs /runs/{id} /runs/{id}/stream
/runs/{id}/result /runs/{id}/population/{gen}
/evaluate. POST /runs accepts engine: v1|v2.
CORS-enabled; SSE via sse-starlette; worker-thread
bridge; _json_finite() walker on every return so the
wire never carries NaN/Infinity tokens.
scripts/ Top-level orchestrators (biology-aware): run_h2.py wires
data + airgap + engine together
validate/ h1.py: known-answer H1 fixture on the NAMED matrix
h2.py: reveal winner + check MMR overlap
tmb_rank.py: signed-Spearman per gene vs TMB on the
NAMED matrix; reports where MMR / IMMUNE genes
land + top-10 most-negative. Powers the
/diagnostic/tmb-rank endpoint and the colorectal
Reference-gene diagnostic panel.
hpv_rank.py: single-gene orientation-agnostic AUROC
per gene vs HPV+/HPVβˆ’ on the engine's HNSC TRAIN
split; reports where CDKN2A + each cell-cycle
gene lands + top single-gene separators. Powers
/diagnostic/hpv-rank and the HNSC Reference-gene
diagnostic panel.
web/ Next.js (App Router) + TS + Tailwind + Recharts +
reactflow Lab page (Stage 1 MVP). Talks to FastAPI;
no separate build pipeline for the engine. Single
<InfoTip> drives every "?" in the UI. Program graph
is a custom React Flow canvas in ProgramGraph.tsx β€”
Tier-1 group per gene-set, Tier-2 wrapper for 2-set
programs, palette tokens only, default chrome stripped.
tests/ pytest, no network (test_dsl.py, test_airgap.py,
test_h1.py, test_build.py, test_engine.py,
test_engine_v2.py, test_api_airgap.py)
```
## Hard rules
- **Stubs stay biology-free.** `dsl/`, `airgap/`, `engine/`, `validate/` must
not hardcode gene names, pathway names, or MSI-specific constants. The data
layer and viewer use real gene symbols freely; the engine only ever sees the
airgapped view.
- **Schema constants live in one place.** `data_pipeline/schema.py` β€”
filenames, required columns, MSI thresholds. If cBioPortal renames a column,
edit there, not in `build.py`.
- **MSI label is derived, not shipped.** cBioPortal has no clean MSI-H/MSS
column. We derive from `MSI_SENSOR_SCORE` per the file's own documented
thresholds (`MSI_SENSOR_HIGH = 10.0`, `MSI_SENSOR_LOW = 4.0`). `build.py`
prints this provenance every run.
- **Presentation lives in `app/theme.py`.** Palette + Altair theme. Don't
override per-chart with `.configure_*`. MSI colours/order: import from
`theme`, never inline. The H1 composition diagram (DOT in `app/viewer.py`)
follows the same palette family: operator nodes stone (`#F4F2EE`), score
meta-concepts cream (`#FBEFE2` / `#BC6B2E`), output nodes cool-tinted
(`#EAF0F2` / `#3A6B7E`), `Given:` input notes muted grey (`#EFEFEA` /
`#9AA0A6`).
- **H1 conclusion is computed, not hardcoded.** `app/viewer.py` derives the
pass/fail callout from three checks: A (MMR median lower in MSI-H), B
(immune median higher in MSI-H), C (held-out AUROC β‰₯ `_AUROC_THRESHOLD`,
currently 0.75). Editing the threshold or any check requires editing
`_section_h1`, not the callout copy.
- **Airgap is load-bearing.** Only `airgap.reveal` may open
`data/processed/_sealed_gene_map.json`. The engine must never import
`reveal`, `airgap.seal`, or reference the sealed-map filename β€” a test in
`tests/test_airgap.py` scans `engine/` for those tokens. At runtime, both
`dsl.Search` and `engine.run_gp_pipeline` assert their input matrices have
columns matching `^g\d+$` only. Biology-aware orchestration lives in
`scripts/` (the cohort label "MSI-H" / column name "msi_status" appears
there and in `validate/`, but never in `engine/` or `dsl/`).
- **H2 artefacts are anonymised on disk.** `data/processed/h2/evolution_log.json`
and `data/processed/h2/result.json` only ever contain opaque IDs. The
reveal step happens at runtime, exactly once, via the FastAPI `/reveal`
endpoint (or `validate.h2.reveal_winner`).
- **Lab payloads are anonymised over the wire.** `tests/test_api_airgap.py`
POSTs synthetic runs against both presets, drains the SSE stream, and
asserts no gene symbol leaks through `/runs/{id}`, `/runs/{id}/result`,
or the stream. `/evaluate` translates only the IDs the client sends β€”
never the whole map.
- **Two engine objectives, both selectable from the API.**
`BinaryAUROCObjective` (MSI/MSS β€” stratified split + StratifiedKFold +
AUROC) and `CorrelationObjective(direction)` (continuous, e.g. TMB β€”
random split + KFold + signed Spearman of the sum-of-set-means with
the target). Each objective owns its own `prefilter_score_per_feature`
and `permute(y)` so the GP/baseline/permutation loop stays generic.
Add a new objective by subclassing `engine.objectives.Objective` and
wiring it into `objective_from_spec`.
- **Prefilter is a strict-speed knob, not a correctness knob.** When
`prefilter_n=None` the GP samples (init + mutation) from the full
opaque column set, so it can compose any gene combination present in
the matrix. When an integer is passed, the GP is confined to the
univariate top-N β€” faster, but a gene that only shows signal in
combination can be dropped. The baseline always uses the univariate
top-K (independent of the GP's pool) so it's a fair sanity check
either way.
- **Fitness never leaves the API as NaN / Infinity.** Engines floor
degenerate-program fitness to the objective's finite worst
(`worst_score()` on `V2Objective`: 0.5 for MSI omni-AUROC, 0.0 for
TMB correlation). The API also runs every endpoint return + SSE
payload through `_json_finite()` so anything that slips through
becomes `None`. Frontend uses one `fmtFit(x)` helper everywhere a
fitness/score is printed β€” falls back to "β€”" for non-finite, and
`fitnessForOrder(x)` sinks non-finite tiles to the bottom of the
grid with the lightest tint.
## Setup & run (Python 3.11 venv in `.venv/`)
```bash
source .venv/bin/activate
python -m data_pipeline.download # cBioPortal -> data/raw/
python -m data_pipeline.build # -> data/processed/*.parquet
pytest
python -m scripts.run_h2 # GP engine -> data/processed/h2/*.json
uvicorn api.app:app --reload # API on http://localhost:8000
streamlit run app/viewer.py # Streamlit viewer (Dataset / H1 / H2 tabs)
# Lab (Next.js front end against the same API)
cd web && npm install && npm run dev # http://localhost:3000
```
## Data provenance
- Study: `coadread_tcga_pan_can_atlas_2018` (TCGA CRC PanCancer Atlas).
- Source: `https://media.githubusercontent.com/media/cBioPortal/datahub/master/public/<study>/<file>`
(the LFS-resolved URL; the S3 tarball mirror 403s, `raw.githubusercontent.com`
returns 131-byte LFS pointers).
- `data_mutations.txt` is **optional** β€” datahub is over its GitHub LFS budget
for that file. Chunk 1 does not need it (TMB comes from
`data_clinical_sample.txt` `TMB_NONSYNONYMOUS`).
- Expected cohort after `build`: ~594 samples (378 COAD, 155 READ, 61 MACR),
20,504 genes after dedup, **558 usable** (MSI in {MSI-H, MSS} ∧ expression
∧ stage ∧ age). Sanity check: median TMB MSI-H β‰ˆ 39, MSS β‰ˆ 3.
## Style
- Default to no comments. The README and this file carry the why.
- Keep `tests/` network-free; use synthetic cBioPortal-shaped fixtures.
- Streamlit charts: `width="stretch"` (the newer API; `use_container_width`
is deprecated in 1.58+).