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 & 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/)

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+).