Spaces:
Sleeping
Sleeping
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 indsl/. Airgap (anonymise/reveal+ sealed map atdata/processed/_sealed_gene_map.json) lives inairgap/. H1 verification (known-answer MMR + immune check on the NAMED matrix) lives invalidate/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 withGiven: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 atscripts/run_h2.py(Load β anonymise β engine β persist artefacts). Artefacts atdata/processed/h2/{evolution_log,result}.json, both anonymised. FastAPI surface inapi/app.py(/health,/run,/result,/reveal) β/revealis 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 invalidate/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 β alongsideBinaryAUROCObjectivefor 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 /healthendpoints, and the Streamlit viewer are untouched. - Chunk 4 follow-ups (done):
- Prefilter optional + bigger GP budget. Engine
prefilter_n: int | None;Nonemeans 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._preparereturnsgp_pool+baseline_genesseparately β the baseline keeps using the univariate top-K so it stays a fair sanity check. API acceptsprefilter_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.pyuntouched (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 theOBJECTIVE_TIPS/PARAM_TIPS/TIPSrecords at the top ofLab.tsxβ never inline. - Program graph (React Flow).
web/app/ProgramGraph.tsxreadsfeature_setsstraight 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#BC6B2Ewrapper (omitted for single-set programs). Custom node types only, palette tokens only; React Flow chrome stripped viaProgramGraph.css(opacity:0handles, 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).
- Prefilter optional + bigger GP budget. Engine
- 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 toMatrixTerminaland Vector toReduce(MatrixTerminal, agg), so no tree ever has an open slot. Reduce.agg vocab also widened indsl.Reduceitself. - Two v2 objectives, with finite worst-case fallbacks:
BinaryAUROCObjective(MSI/MSS β orientation-agnosticmax(AUROC, 1βAUROC), worst = 0.5) and the v2CorrelationObjective(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 /runsacceptsengine: "v1" | "v2". v2 worker usesengine_v2.run_v2_pipeline_streaming. Full population persisted per generation inrun.log[i].candidates(both engines); SSE stream keeps the bandwidth budget light by trimming to top-12.GET /runs/{id}exposesgenerations_persisted; full per-gen populations come fromGET /runs/{id}/population/{generation}(404 for unpersisted indices, never 500). All endpoint returns, the in-memoryrun.result, and the SSE payloads pass through_json_finite()β NaN / Β±β βNone, soJSON.parsenever breaks. - Frontend (web/): Lab now posts
engine: "v2". Replaced the old top-12 grid withPopulationTiles(full per-gen population, fitness-tinted teal ramp, "NnΒ·dD" structure signature, generation stepper bounded togenerations_persisted). Newlib/programRepr.tsis the SHARED parser for the typedprogram_repr(also tolerates the legacy v1Fit(β¦)shape).ProgramGraph.tsxwas rewritten to consume the parsed tree and now renders arbitrary Select/Reduce/Combine/M shapes; same custom nodes, sameProgramGraph.csschrome strip.PasteToDraw.tsxfeeds arbitraryprogram_reprtext into the same renderer.ParameterFlow.tsxadds 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 singlelib/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 usefitnessForOrder()so any non-finite sinks to the bottom of the grid with the lightest tint.
- engine_v2/ β strongly-typed program synthesis. Programs are
trees over a five-type grammar (
- 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.ParamHelpProvidermounts 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 inweb/app/paramHelpContent.tsx; six diagrams (Generations loop, MaxSets, Lambda trade-off, Seed dice, Permutations histogram, Prefilter funnel) live underweb/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.tsxnow adds Tier-1 (light teal#F1F6F7/#3A6B7E) and Tier-2 (dashed#BC6B2E) wrapper nodes when the parsed root isCombine(single- score programs skip wrappers).PasteToDraw.tsxis 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,Reduceaggs,Combineops, rules, annotated example). - Copy & load. New
web/app/CopyButton.tsx(navigator.clipboardwithexecCommand("copy")fallback) drives a "Copy program" button next to the winner in the Result panel and an icon-only copy overlay on every tile inPopulationTiles. Tile body usesrole="button" tabIndex={0}so the inner icon-button can stop propagation; clicking the body loads the program into the textarea AND the graph.
- Rich Parameter help. New
- 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)βScalarandEffect(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)β bundlesM(opaque),clinical(named: stage, age only), andlabels(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,
-xfor 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.tsparses every new operator (spaces tolerated).ProgramGraph.tsxrenders 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. OldOBJECTIVE_TIPSconstant 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,tmbflow 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.
- Full DSL grammar in engine_v2. Typed grammar grew from
{Matrix, Vector} to {Matrix, Vector, Scalar, Model}. New nodes:
- 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.synthesizenow threadsobjective_targetthrough_grow_scalar,_grow_vector,random_program,ramped_population, andmutate. EveryAssociate/Effect/FitApplyis constructed withtarget = objective.target.- Point mutation no longer carries a
fit_targetspot and thescalar_kindspot 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 assertsn.target == expected.fitness_fncalls it first and floors any mismatch toWORST_FITNESS, so a stray target β even from a replayed tree β can never win.program_reprstill 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) andtest_fitness_floors_mismatched_target_to_worst(hand-crafted bug-shape program is floored toWORST_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>atviewBox.y + viewBox.height/2withtext-anchor="middle"β the title now sits beside the middle of the axis, not the top. Lines: best solid#3A6B7EstrokeWidth 2.5, median dashed (4 4)#6E7F8CstrokeWidth 1.5. - InfoTip copy.
TIPS.fitnessCurveandTIPS.bestVsMedianrewritten 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:draftGenupdates on every input event for live number, but the/runs/{id}/population/{gen}fetch is keyed oncommittedGen, which only updates onpointerup/pointercancelor 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.
- Fitness curve chart. Recharts' default
- 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.pyand a smallGET /diagnostic/tmb-rankAPI endpoint. Mirrorsapi._prepare_lab_datatarget="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 invalidate/(allowed gene names) β the structural engine airgap test still scans onlyengine/. 3 new tests intests/test_tmb_rank.py. - MMR biology panel.
web/app/MMRBiologyPanel.tsxmirrorsParameterFlow.tsx's collapsible exactly (button + chevron + aria-expanded + useId-panel-id + closed-by-default). Body inlinesmmr_reference.svgverbatim (causeβeffect: MMR β MSS / MSI-H β immune response, plus the gene-expression visibility readout). Mounted between the Objective card and Parameters card inLab.tsx. - MLH1 success callout (TMB).
/diagnostic/tmb-rankis now fetched at the Evaluator level (lifted out of the innerTMBRankPanel) 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."OverlapSummarygains 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_OBJECTIVEwithtarget="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 viagp.pyintoramped_population/mutate._build_ctxsstrips all labels when target='none' (asserted) so the engine literally cannot see msi/tmb during search. Newunsup_random_nullinpermutation.pyβ N random Vector-only programs scored on held-out (no target to shuffle);permutation_summary.null_kinddistinguishes the two nulls in the result. - Post-hoc alignment. Worker thread for unsup target runs
_compute_unsup_posthocAFTER the GP finishes: orientation-agnostic AUROC of winner scores vs MSI (held-out subset, gated nβ₯10 per class) and|spearman|vs TMB. Lives inapi/_worker(allowed labels) β never in engine_v2. Newresult.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.
Targetwidened to"msi" | "tmb" | "none";OBJECTIVE_PRESETS.none,FITNESS_LABEL_BY_TARGET.none. Survival entry removed fromObjectiveBuilder+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. TheRunResultinterface inweb/lib/api.tsgainsposthoc?: 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-centricTIPS.fitnessCurvewithFITNESS_TIP_BY_TARGET: Record<Target, string>and plumbedtargetintoLiveViewso the InfoTip matches the y-axis title's objective-awareness. AddedTIPS.nodesandTIPS.genesand passed them to the NODES + GENESMetriccards in the ResultPanel. RewroteObjectiveIntroso it no longer asserts "a target column" unconditionally; rewroteobj_msi/obj_tmb/obj_unsupervisedmodal bodies verbatim per prompt.obj_unsupervisedno longer renders the target-centricObjectiveFooterβ newUnsupObjectiveFooter("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; ifstd_w < 1e-9β worst floor. This catches theprotected_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_splitlocks 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_matrixnow takesobjective_target; under unsup every Matrix leaf isSelect(MatrixTerminal(), sampled FeatureSet)(never bareMatrixTerminal),Searchis gated off, and the_grow_vectordepth-floor becomesReduce(Select(M, [...]), agg)._grow_vector's Split branch forcespredicate="score"under unsup; point mutation'spredicatespot is suppressed entirely for unsup so a"score"can't be flipped to"stage_late"mid-run. Belt-and-braces fitness floor:fitness_fnandevaluate_holdoutreturnWORST_FITNESSwhenobjective.target == "none"andprogram.feature_ids() == []. Two new tests (test_unsupervised_programs_always_use_gene_selectover 120 programs Γ 20 mutations, andtest_fitness_floors_no_select_program_under_unsupon 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.
- TMB-rank diagnostic. New
- Chunk 6 follow-up (done β generalisation-aware unsup silhouette +
plain-reading verdict):
- OOS silhouette in
cv_scoreANDevaluate_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). NewV2Objective._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 viaKMeans.predict. Guards live on the TEST side (β₯30% per cluster, near-constant after clipping β worst).cv_scoreper fold now uses(tr, te)not justte.evaluate_holdoutgained an optionalctx_trainkwarg; 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_nullgainedctx_trainand forwards it intoevaluate_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 plumbsctx_traininto both the winner'sevaluate_holdoutand the null-distribution loop. - Plain-reading verdict at the top of the Result panel. New
<ResultVerdict>rendered first insideResultPanel. Per-target thresholds (MSI 0.75, TMB 0.30, unsup 0.30) plusp < 0.05gate 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 onposthoc.msi_auroc(β₯0.75 β "rediscovered the subtype"; <0.75 β "doesn't line up with MSI"; null β "too few held-out labels"). ConstantsHOLDOUT_THRESHOLD,P_VALUE_SIGNIFICANT,POSTHOC_MSI_ALIGNat the top ofLab.tsxare 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.
- OOS silhouette in
- 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).
- chevron +
- 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 throughfmtFitso non-finite renders as em-dash.
- The verdict callout now has a collapsed-by-default disclosure
underneath it (mirrors
- Chunk 6 follow-up (done β iterative unsupervised discovery,
"peel off axes"):
- Engine.
engine_v2/pipeline.pygained_residualise_matrix(M, residualize_scores)β vectorised OLS vianp.linalg.lstsqover[intercept, *priors]. Rows missing a prior score are dropped BEFORE the train/test split so train and test see the same residualised feature space. Bothrun_v2_pipelineandrun_v2_pipeline_streaminggainedresidualize_scores: pd.DataFrame | None = None(no-op for MSI/TMB; only the unsup API worker sets it). Both functions now emitwinning.full_scores+winning.full_sample_ids(full- cohort per-patient scores) alongsideholdout_scoresβ these feed the chain's next residualisation step. - API.
RunRequest.residualize_against: list[str] | None._assemble_residualize_dfvalidates each prior (must exist + be done +target=="none"+ havefull_scores); turns_json_finite'sNoneback intoNaNso the pipeline's NaN filter drops those patients during residualisation.post_runsvalidates the chain BEFORE spawning the worker β supervised target + priors β HTTP 400; unknown / wrong-target / no-scores prior β HTTP 400 with the specific reason._workergainedresidualize_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 intolaunchRun(opts?)so a fresh Run (launchRun(), clearsaxes) andfindNextAxis()(launchRun({residualize_against: axes.map(a=>a.run_id)}), preserves the chain) share the same SSE wiring. The SSEdonehandler appends unsup results toaxes. 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. TheTIPS.discoveredAxestooltip flags the linear-residualisation heuristic and the in-memory-chain caveat. - Tests. Extended
tests/test_api_airgap.py:test_unsupervised_run_emits_posthoc_alignmentnow assertswinning.full_scores+winning.full_sample_idsare populated; newtest_residualisation_chain_runs_and_stays_airgap_cleanposts axis 1 then axis 2 withresidualize_against=[axis1]and confirms the second payload is opaque-only and carries its ownfull_scores; newtest_residualize_against_unknown_id_returns_400test_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.
- Engine.
- Chunk 7 (done β second dataset: HNSC + HPV detection):
- Dataset pipeline. New
data_pipeline/{download_hnsc,build_hnsc}.pymirror the colorectal scripts for TCGA HNSC PanCancer Atlas (hnsc_tcga_pan_can_atlas_2018, LFS-resolved media URL). The build script deriveshpv_statusβ {HPV+, HPVβ} from whichever column carries it: firstdata_clinical_sample.txt::HPV_STATUS*, thendata_clinical_patient.txt::HPV_STATUS, then theSUBTYPEsuffix fallback (e.g.HNSC_HPV+/HNSC_HPV-). On the real cohort the resolved source ispatient.SUBTYPE. Writesdata/processed_hnsc/{clinical,expression}.parquet(gitignored). HNSC schema constants live indata_pipeline/schema.pynext to the colorectal ones; final usable cohort is 487 samples (72 HPV+ / 415 HPVβ). - Loader.
dsl.Loadalready handled directory paths via itselsebranch; the only change is thatCohort.labelsnow picks uphpv_statusif present.Load(schema.HNSC_PROCESSED_DIR)returns the HNSC cohort withhpv_statusincohort.labels. - HPV objective (engine_v2).
V2Objective.targetwidened toLiteral["msi","tmb","none","hpv"]. NewHPV_OBJECTIVE(binary=True, worst=0.5) routes through the SAME orientation-agnostic AUROC machinery as MSI;score_scalarmirrors MSI'sabs(value)orientation.objective_from_specdispatcheshpv + auroc/auroc_omni. No new operators; no other engine surface changes. - API per-(dataset, target).
_prepare_lab_data(target, dataset)keyed byf"{dataset}:{target}". New(hnsc, hpv)branch loads HNSC, restricts to called HPV samples + complete expression, anonymises, setsy = (hpv_status == "HPV+").astype(int). New(hnsc, none)unsup branch keeps every patient with complete expression and carries the HPV label aside viaextra_labelsso_compute_unsup_posthoccan compute orientation-agnostichpv_auroc(mirrors themsi/tmbbranches; new keyPosthoc.hpv_auroc?).RunRequest.dataset+Run.dataset+ server-sideDATASET_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_SETSwithREFERENCE_SETS_BY_DATASET. Colorectal keeps MMR + immune; HNSC shipsp16 = ["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.datasetis required and cross-dataset reference-set requests return 400. - Frontend dataset registry.
Targetwidened to includehpv; newDatasetId = "coadread" | "hnsc".DATASET_REGISTRYinLab.tsxdrives 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 inlineshpv_reference.svgverbatim (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.hpvreuses 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 fromDATASET_REGISTRY[dataset].refSetKeys. HNSC shows p16 | cell_cycle; colorectal stays on MMR | immune.<MLH1SuccessCallout>+<TMBRankPanel>gated todataset === "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_cyclevia 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.pyfake_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.
- Dataset pipeline. New
- Chunk 7 follow-up (done β HPV-marker-rank diagnostic):
Mirror of the TMB-rank diagnostic for HNSC. New
validate/hpv_rank.pyranks 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)thenmax(AUROC, 1βAUROC); sub-second over ~20k genes. NewGET /diagnostic/hpv-rankendpoint mirrors/diagnostic/tmb-rank(lazy import,_HPV_RANK_CACHE,_json_finite()-wrapped). Three synthetic-cohort tests intests/test_hpv_rank.py. Frontend:HPVRankDiagnosticinterface +getHPVRankDiagnostic()inweb/lib/api.ts; new<HPVRankPanel>inLab.tsxreuses the existing<RankList>chrome and renders p16 + cell-cycle + top-N separators with the prompt's verbatim caption, gated todataset === "hnsc" && target === "hpv".TIPS.hpvRankdocuments 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 walksengine/). - 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>receivedataset(or infer it fromposthoc.hpv_aurocpresence) and look up the right copy. - Plain English everywhere. Rewrote the verbose / typo'd
legacy
TIPS.heldOut,TIPS.permutationP,TIPS.posthoc,TIPS.discoveredAxes,TIPS.referenceSetto crisp sentences with one-line glosses for jargon (AUROC, held-out, permutation). Fixed theseedPARAM_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.
ObjectiveIntronow lists MSI / TMB / HPV (was MSI / TMB only).UnsupObjectiveFootermentions both MSI (colorectal) and HPV (head & neck) as legitimate downstream alignments instead of MSI only. - No all-caps anywhere. Stripped Tailwind's
uppercaseutility from all 13 eyebrow / table-header sites inLab.tsx+PasteToDraw.tsx. RemovedtextTransform: "uppercase"from the Tier label inProgramGraph.tsx. Upcased the underlying literal strings that were relying on the CSS (Winning program,Revealed genes,Opaque ID/Symbol/Matchedtable headers, rank-tableSymbol/Correlation/Rank / N/AUROC).grep -rn "uppercase\|textTransform" web/appreturns zero hits. - Program-graph clipping fix.
ProgramGraph.tsxgained a remountkey={fitKey}derived fromwidth Γ height Γ node-count Γ edge-countso ReactFlow's one-shotfitViewre-runs every time the laid-out program changes (winner β candidate click, paste, dataset swap). LoweredminZoomfrom 0.4 to 0.2 sofitViewcan scale a full two-tier graph down to fit the card width. Confirmed nooverflow: hiddenon the wrapping<SectionCard>.<ProgramGraph outputLabel>now dispatches by target βMSI-H probability/HPV+ probability/TMB association/cluster scoreinstead of MSI-only.
- Objective-aware tooltips. Added four per-target / per-dataset
records next to
- 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 with0/7cell-cycle overlap. The dominant term detected HPV from bulk expression, not from gene choice. Fix:engine_v2/synthesize.pymakes the Select-wrapping rule unconditional for every objective (was unsup-only). Every Matrix leaf is nowSelect(MatrixTerminal, FeatureSet); the Vector depth-floor returnsReduce(Select(M, β¦), agg); the bare-MatrixTerminal fall-through is gone. Belt-and-braces inengine_v2/fitness.py: new_has_bare_matrix_reduce(program)walks the tree and any Reduce on a bare MatrixTerminal floors toWORST_FITNESSfor every objective (was unsup-only). Theno Select β WORST_FITNESSfloor is also no longer gated to unsup. Two test fixups: the closed-Matrix leaf at depth 0 is nowSelect(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 nowReduce(Select(M,[g17705]),max)β a single-gene detector, AUROC 0.964, no bareReduce(M,β¦). - Detection vs recovery (presentation).
HPVBreakdownContentMSIBreakdownContentsuccess 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 onposthoc.hpv_auroc β₯ 0.75.
- Name AUROC where the metric IS AUROC. New
HELD_OUT_LABEL_BY_TARGETβ Held-out card label isHeld-out AUROCfor MSI / HPV,Held-out (|spearman|)for TMB,Held-out (silhouette)for unsup.HELD_OUT_TIP_BY_TARGETrewritten 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 switchedHeld-out separationβHeld-out AUROCwith the same gloss. - Per-revealed-gene rank in Reveal & evaluate.
/evaluaterequest gains optionaltarget; response rows gain optionalrank/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 reusingvalidate/hpv_rank.py's_auroc_per_column(on the engine's TRAIN slice) andvalidate/tmb_rank.py's_spearman_per_column; cached for the process lifetime. Frontend:EvaluateRowgained the optional fields;postEvaluatepassestarget; the Revealed-genes table now shows a "Single-gene rank" column with{rank} / {total}plusAUROC X.XXX(HPV) orΟ X.XXX(TMB). Column appears only when at least one row carries a rank β MSI and unsupervised omit gracefully. NewTIPS.singleGeneRankexplains 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.
- Engine. Motivating live run produced
- 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_fngainedcoherence_weight: float = 0.0; when > 0, fitness becomesbase β λ·n_nodes + w Β· coherence. Threaded throughgp.py+ bothpipeline.pyentry points with default 0 so existing runs stay byte-for-byte unchanged. API: newRunRequest.coherence: bool = False;_workertranslates toCOHERENCE_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.coherencecarries the prompt's verbatim copy. - Peel-off for supervised objectives. Both pipeline entry
points now persist
winning.full_scores+full_sample_idsfor every objective (theis_unsupgate is gone in both spots).api.app.post_runsno longer requirestarget=="none"forresidualize_against;_assemble_residualize_dfvalidates that every prior shares both(dataset, target)with the new run. Frontend<DiscoveredAxes>mounts after ANY run (not just unsup) and the SSEdonehandler 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_targetsposts 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}, β¦]}. Therankslist is opaque-only (~20k rows).reference_marksis 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_REGISTRYdrops thenoneobjective entry for both datasets β UI now shows coadread β {MSI, Mutation burden} and hnsc β {HPV detection}.UNSUP_OBJECTIVE, the unsup pipeline branches, thetarget === "none"frontend code paths (verdict / breakdown / post-hoc / Evaluator fallback), andtests/test_engine_v2.pystay intact β just unreachable from the UI. - 108 / 108 pytest pass; tsc clean; airgap tests still green.
- Coherence prior (engine). New
- Chunk 7 follow-up (done β Ranking UI polish): Presentation-only
pass on
<RankingResult>. New three-wayRANK_COLORSpalette (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 BEFOREmake_split, so the OLS projection saw the held-out rows and smeared target signal into them.engine_v2/pipeline.pyβ_residualise_matrixreplaced 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). Bothrun_v2_pipelineandrun_v2_pipeline_streamingreordered: align β split β fit Ξ² onM.loc[split.train_ids]β apply Ξ² to full M β prefilter / GP /evaluate_holdout.winning.full_scoresre-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βExecContextgainedfit_ctx: ExecContext | None = None.FitApply.executenow runs the inner subtree onfit_ctx(train) to fit LR/OLS and applies the FROZEN model to the test inputs (no fitting on test labels). Binaryhpvnow routes through the same logistic branch asmsi. 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_holdoutwraps the testExecContextwithfit_ctx=ctx_trainwhenctx_trainis 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 toWORST_FITNESS. Genuine multi-gene synergy is unaffected.- Tests β
tests/test_engine_v2.pygainedtest_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) andtest_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}/modulesendpoint (api/app.py::_compute_module_ranking). Reproduces the run's EXACT train/test split via the persistedfull_sample_ids/holdout_sample_idsso "held-out" actually is held-out; harvests distinct candidates'gene_idssets (β₯ 2 genes) across every persisted generation (engine_v2 already stores a flatgene_idslist 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 carriesref_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-rankuses forreference_marks). Sorted by combined held-out desc; non-finite sinks to the bottom.Rundataclass gainedcoherence: bool;/runs/{id}now surfaces it (along withdataset). Endpoint returns 425 while running, 404 for unknown, 400 for unsupervised runs (no target to evaluate against). - Frontend. New
<ModuleRankingPanel>rendered after<RankingResult>, gated totarget !== "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-wayRANK_COLORSpalette (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 callsPOST /revealwith 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_setsset NAMES only β no gene symbols. Reference-set membership comes from the same bounded sealed-map lookup/diagnostic/full-rankalready uses. Per-module symbols revealed lazily per expanded module via/reveal. New airgap tests intests/test_api_airgap.py:test_modules_endpoint_returns_opaque_only_modules(assertref_setslist 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 --noEmitclean. - 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.
- API. New
- 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.rankingResultTIPS.rankingHighlightedlead with INDIVIDUAL;TIPS.module Rankingleads 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 awinnerbadge 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 aRecord<string, string>keyed by opaque ID and ONE batched/revealcall per page (bounded by β€ 25 Γmax_genes_per_setIDs β 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
Β· +Noverflow chip; expanding shows them all. The expanded<ModuleGeneTable>reads from the sharedsymbolByOpaquecache (no second fetch).
- 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
- No engine/API/airgap changes.
tsc --noEmitclean;pytest -q114 / 114; airgap suite untouched.
- Single-gene "Highlighted genes" table retags winner-source
rows from
- 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.pynow writesrace,ethnicity,tissue_site(raw TUMOR_TISSUE_SITE),icd_o_3_site(ICD-O-3 topography from the patient file), and a derivedis_oropharynxboolean (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_datawidens 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. Effectis now configurable.ExecContextgainedconfounders: tuple[str, ...] = ("stage", "age")β default preserves byte-for-byte legacy behaviour (MSI / TMB / HPV runs unchanged).Effect.executereadsctx.confoundersand builds a one-hot-encoded design matrix from any columns present inctx.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_agetest_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_rankingno longer discardsclinical. For HNSC/HPV runs it slicesis_oropharynxto the held-out test patients, recomputes each module's mean-aggregate AUROC within the oropharynx subgroup, and emitscombined_holdout_oropharynxsurvives_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 inapi/app.py), resolved to opaque IDs via the sealed map β same bounded-reveal pattern/diagnostic/full-rankuses forreference_marks; never leaves the API layer. Bottom tertile of the proxy on TEST = high-purity subset; each module getscombined_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 compactsite β 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. NewTIPS.module Survival?explains the flags.TIPS.moduleRankinggained 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 -q116 / 116 (was 114; +2 Effect confounder tests). Airgap suite 25 / 25 green β module payload still opaque-only.tsc --noEmitclean. 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.
- HNSC build.
- 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 renderssite β 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 renderssite β(unchanged). Tooltip wording updated to spell out the full-cohort β subgroup pair. - Known-marker recovery panel = reference markers only.
<RankingResult>'spinnedRowsno longer pushes the winner'skind:"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) whenwinnerOpaqueIds=[]. The captions andTIPS.rankingResult/TIPS.rankingHighlightedrewritten 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 takesdataset/target/runId/coherencefrom the Lab parent and fetchesgetFullRankDiagnostic(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 viapostReveal(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 indiag.ranksand showsSYMBOL #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
moduleDatavia unordered gene-set equality (same notion<ModuleRankingPanel>::isWinnerSetuses), 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."
- Its genes, each on its own: for each
- Verification.
tsc --noEmitclean;pytest -q116/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.
- Survival chips show full β subgroup.
- 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 fetchesgetRunStatus(runId)β lastgenerations_persisted - 1βgetRunPopulation(runId, lastGen), dedupes thecandidatesby 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-geneSYMBOL #rank / N(looked up indiag.ranks) and<SurvivalChips>for the program's matching module (gene-set equality the same notionModuleRankingPanel::isWinnerSetuses). #1 is the winner and is highlighted with the accent-teal palette + awinnerchip. - Bounded reveal. The reveal cache (
symbolsByOpaque) is populated by a single batchedpostRevealover 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.moduleRankingrewritten 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.rankingResultreworded to point at the GP's top programs and the after-the-fact re-scoring panel by name. - Verification.
tsc --noEmitclean;pytest -q116/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.
- TopProgramsBlock replaces
- 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_v2gainsimmigrant_fraction: float = 0.0. When > 0,round(immigrant_fraction * population_size)slots in the new generation are filled with fresh programs drawn fromramped_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_kandp_mutatewere already parameters; raising mutation and lowering selection pressure are paired with immigrants via the same toggle.- Pipeline + worker plumbing.
run_v2_pipelineandrun_v2_pipeline_streamingaccept the newimmigrant_fraction: float = 0.0.RunRequestgainsdiversity: bool = False.Rundataclass gains adiversityfield; surfaced in/runs/{id}._workermapsdiversity=Trueβtournament_k=2,p_mutate=0.85,immigrant_fraction=0.10;Falseβ current(3, 0.7, 0.0). Two new tests intests/test_engine_v2.py:test_default_run_unchanged_by_diversity_param(passingimmigrant_fraction=0.0explicitly produces an identical best- fitness trajectory to omitting the arg) andtest_immigrant_fraction_injects_fresh_programs(withfrac>0the last generation contains β₯1 immigrant β empty-parents and non-survived β and more distinctprogram_reprstrings 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 -q118/118 (was 116; +2 diversity tests).tsc --noEmitclean. 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_rankingnow records the max GP fitness per gene-set while harvesting candidates and emitsgp_fitness: float | Noneon each module dict. Fitness is a number β no gene names cross the wire.RankedModuleinweb/lib/api.tsgainsgp_fitness?: number | null. - Frontend β merged table.
<ModuleRankingPanel>retitled "Groups the engine explored".SortKeyextends 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.moduleRankingrewritten to explain all four lenses + keep the winner's-curse caveat for the re-score lenses. - Frontend β drop TopProgramsBlock.
<ResultPanel>no longer fetchesgetRunStatus/getRunPopulation/postReveal/getFullRankDiagnostic/getRunModules; the entireTopProgramsBlockfunction definition and its supporting state are gone. Result-panel signature simplifies to{ result, dataset, target, runId, coherence }withdataset/runIdonly 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 totarget !== "none" && coherence(the same gate the merged table uses). Unused imports (Candidate,getRunStatus,getRunPopulation) removed. - Verification.
pytest -q118/118 (no Python tests changed β the new field is additive, the old payload tests still pass).tsc --noEmitclean. 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.
- API.
- 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 inweb/app/Lab.tsx). x =m.gp_fitness, y =synergyOf(m)(lifted to a module-scope helper so the table + scatter share one definition). Categorisation viacategoriseModule(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 Γ dprctx.setTransform(dpr, 0, 0, dpr, 0, 0). Plot rect responsive via aResizeObserveron 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 --noEmitclean;pytest -q118/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 ahitsRef.current: Hit[]array. The faint grey background dots are deliberately NOT in this array β they are not revealed and are not hit-tested. Selection:mousemovewalks the array and picks the nearest hit within ~8px (radiusΒ² = 64);mouseleaveclears. - Bounded reveal. On mount / when
moduleschanges, the scatter takes the UNION of all highlighted modules'gene_idsand issues ONE batchedpostRevealcall β 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 inhighlightSymbols: 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 viafmtFit(non-finite β em-dash). Synergy uses the sharedsynergyOfhelper 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 --noEmitclean;pytest -q118/118 (no Python changes). Live HNSC seed-11 coherence-on: hovering the winner ring showsRNF32, 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.
- Hit-test scope. When painting,
- 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(ornull/ "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 mostlynullon 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.
- solid (fill + stroke) when
- Hide-failed toggle. Added a small "Hide groups that fail
the site check" checkbox in the scatter header (default OFF).
On:
survives_site === falsehighlighted 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.moduleSurvivalrewritten 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 --noEmitclean;pytest -q118/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.
- Scatter site-survival encoding. Highlighted dots in
- 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.
- Default to Head & Neck + HPV detection.
datasetstate in Lab now defaults to"hnsc", andtargettoDATASET_REGISTRY.hnsc.objectives[0].keyso first load lands on the HPV demo of the workflow. Colorectal stays selectable; only the default changed. - Hide the Discovered axes panel.
<DiscoveredAxes>is now gated totarget === "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. - Fix the "(TCGA COADREAD" subtitle clipping. Not a CSS
issue β the old regex
/^[(]|[)]$/g.replacewas stripping the trailing). Replaced withentry.longLabel.replace(new RegExp('^${entry.label}\\s*'), '').trim()so the parenthesised tail renders intact, e.g.(TCGA HNSC). - "Survives"
?is a click-to-open modal now. Multi-paragraph copy didn't fit a hover tooltip β clipped on the right.paramHelpContent.tsx::ParamKeyextended with"module_survival"and a newPARAM_HELP.module_survivalentry 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-chipfull β subgrouprow tooltips are unchanged. - HPVBiologyPanel β new card-wall figures.
HPVBiologyPanel.tsxnow renders TWO inlined SVGs:<NormalCellDiagram>(the new repo-rootnormal_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-roothpv_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>keepsviewBox+ a width:100% / height:auto wrapper so it scales without overflow.
- Verification.
tsc --noEmitclean;pytest -q118/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.
- Default to Head & Neck + HPV detection.
- 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 / populationField 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.tsxreplaced: "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_fitnessis UNCHANGED (API contract). Code comments still say "GP" by choice β they're not user-facing.
- module-ranking subtitle, and TIPS prose. The internal data
key
- DSL operator-usage endpoint + tiles.
- New
GET /runs/{run_id}/operator-usagewalks every candidate in every persisted generation, counts each operator token (Select(,Reduce(,Combine(,Split(,Associate(,Effect(,FitApply(,Search() in theprogram_reprstrings, 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 SSEdonehandler; Lab caches the result inoperatorUsagestate. New<DSLVocabularyTiles>driver swaps each<DSLVerbCard>for an enhanced version that adds aused NΓ β in P% of programsline 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.
- New
- Verification.
pytest -q121/121 (+3 new airgap tests).tsc --noEmitclean. 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.
- Generations / Population caps raised. Lab's NumField config
bumps Generations max 100 β 1000 and Population max 500 β 3000;
server-side
- 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 existingis_unsupgate at the injection site still forbids Search on unsupervised runs. run_gp_v2gainsrates_override: dict | None = None. Insiderun_gp_v2the override is merged on top of the objective's synthesis-overrides (UNSUPetc.) β caller's keys win β and the merged dict is threaded into everyramped_population/mutatecall. Both pipeline entry points (run_v2_pipeline,run_v2_pipeline_streaming) exposerates_overrideand forward it through.- API.
RunRequestgainsenable_search: bool = True.Rundataclass tracks it;/runs/{id}surfaces it._workerbuildsrates_override = Nonewhen 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 innersynthesize._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_searchplumbed 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_zeroverifies the toggle: withenable_search=Falsethe operator- usage payload reports Search at 0 total uses / 0 programs, and/runs/{id}surfaces the flag. The existingtest_operator_usage_endpoint_returns_opaque_countsrelaxed 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 -q122/122 (+1).tsc --noEmitclean. 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.
- Engine.
- 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_searchis gone; replaced by a flatRunRequest.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.Rundataclass stores the dict;/runs/{id}surfaces it._workersplits the flat dict back intorates_override(overlay onengine_v2.synthesize.DEFAULT_RATES) andscalar_share_override, then forwards both to the pipeline. - Engine.
run_gp_v2gainsscalar_share_override: float | None = None; merged with the objective's overrides via aneffective_scalar_sharethat wins overoverrides.get("scalar_share"). Both pipeline entry points expose the new param and forward it. - Frontend. New
DSL_DEFAULT_RATESconstant 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 separatescalar_sharefield 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 / absentrates_overridefield 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
enableSearchstate/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}. TheOperatorUsageopaque-counts test stays the same. 122 / 122 pytest pass; airgap suite green. - Verification.
tsc --noEmitclean. 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 oldenable_search: falsebehaviour./runs/{id}surfacesrates_override: {"search": 0.2}for the UI.
- API.
- 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;tscclean.- 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 chartHis now derived fromnumRowsso 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.
- Scalar share copy rewritten in plain English. The native
- 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_rankingwalks the population once and now tracks BOTH the per-set maxgp_fitnessAND the correspondingprogram_reprin a sibling dict (best_program_repr_by_key). Each module payload emitsbest_program_repr: str | None. Opaque-safe βprogram_repris built from opaque IDs only. - Types.
RankedModule.best_program_repr?: string | nullinweb/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- revealedsymbolByOpaqueadapted to ProgramGraph'sRecord<id, {symbol, matched}>shape (matched=false β the known-marker highlight is a separate concern from per-group trees). The rawprogram_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 --noEmitclean;pytest -q122/122; airgap suite green. Live HNSC seed-11 coherence-on: 297/297 modules carrybest_program_repr. The winner's row'sbest_program_reprmatches the Result-panelwinning .program_reprexactly (Reduce(Select(M,[g12850,g18272]), mean)on this seed). Airgap scan of the modules payload finds no gene-symbol leak.
- API.
- 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_hpvmodal rewritten to the verbatim HPV-detection intro + AUROC gloss + "detecting a known viral fingerprint" honest note; HPV-biology caption; Parameters subtitle + everyPARAM_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.bestVsMedianverbatim 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.opaqueIdspolished. - Jargon sweep clean. No
Server-Sent Events,SSE,on the backend,worker thread, orairgappedin visible copy. tsc --noEmitclean; 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.pygained a GSE65858 block (series-matrix URL, GPL10558 platform URL, raw/processed dirs, HPV label constants).data_pipeline/download_gse65858.pymirrorsdownload_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.pyparses the GEO series-matrix format, derives the STRICT virus-activeHPV+(DNA+RNA+) label from the jointhpv16_dna_rnacharacteristic 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), writesdata/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, testabletransfer_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/+1smoothing). Finite-guarded; gracefulnullpayload whenn_found == 0. 5 new tests intests/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 viaairgap.reveal(same discipline/evaluateuses). Lazy-importsvalidate.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 β soAARSinsideAARSD1doesn't false-positive; 425 mid-run). - Frontend.
TransferResulttype +getRunTransfer(runId)fetcher. New<ExternalValidation>component at the end of<ResultPanel>, gated todataset === "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 onauroc β₯ 0.75 && p < 0.05; three metric cards (Independent AUROC Β· Permutation p Β· Genes measurablen_found / (n_found+n_missing)) each with its own plain?;<Metric>gained asubprop 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 -q130/130 (+8 new tests; +5 transfer- 3 airgap). Airgap suite green.
tsc --noEmitclean. Live end-to-end: HNSC HPV seed-11 winner[g12850, g18272]β symbolsPCBD2, 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.
- 3 airgap). Airgap suite green.
- Data pipeline.
- Chunk 7 follow-up (done β
scripts/multiseed_hpv.pymulti-seed stability orchestrator): Read-only biology-aware script that runs the HNSC/HPVengine_v2pipeline 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 viaairgap.reveal(scripts/is allowed to be biology-aware; same discipline/evaluateuses); 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 dedicatedawaitRunDone(runId)helper opens anEventSourceand resolves on thedoneevent without callingsetResult/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
cancelRefbetween 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:
<StabilitySummaryCallout>β deterministic, LLM-free, composed from the aggregates in JS. Rendered above the dot- strips only whenstabilityStatus === "done". Green ifperfStable && geneStable(held-out min β₯ 0.75, every GP p < 0.05, and top-recurring gene in β₯ βN/2β seeds); teal / informative ifperfStable && !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".- "Does the result hold across seeds?" β two horizontal
dot-strips on a 0.5 β 1.0 axis (
0.5 coin-flip / 0.75 / 1.00tick labels). Accent-teal dots for Held-out AUROC (fromwinning.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 carriesminβmaxviafmtFit. - "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. - Per-seed table:
seed | held-out AUROC | p | indep. AUROC | indep. p | genes found / total. Every number throughfmtFit; error seeds showerrorwith the message on hover.
- Reveal discipline β per seed,
postReveal(winning.gene_ids)(bounded) sources the winner's symbols; then onegetFullRankDiagnostic(dataset, target)at the end for the reference-set tag map (p16wins overcell_cyclewhen a symbol is in both). No new reveal surface. tsc --noEmitclean.pytest -qunchanged (frontend-only). Sanity-tested the data path viaTestClient: 3 seeds Γ tiny budget produces 3 completed runs, each withholdout_score,permutation_p, revealed symbols, and a live/transferpayload; the recurrence tally rolls up correctly.
- State. Own
- Chunk 7 follow-up (done β
scripts/capability_ratio_test.pyknown-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 underscripts/(allowed to be biology-aware); READS the existing processed matrix + REUSES the sealed map viaairgap.anonymise/airgap.revealfor 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 in0.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-seedrun_v2_pipelinewithV2Objective(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}ANDholdout_score β₯ 0.80AND theprogram_reprcontains aCombine(with one ofprotected_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 atk β₯ β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 -qstill 130/130 (nothing outsidescripts/was touched).
- 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:
- 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. Mirrorsvalidate/tmb_rank.pyexactly (same cohort loader shape, same NAMED matrix, reuses_spearman_per_columnverbatim); lives invalidate/(structural airgap test scans onlyengine/); sub-second on the live cohort. No engine / API / UI / data change.- Pipeline.
Load("processed")filtered totmb.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(guardstd > 0) β pooled into one residual vector aligned toX.index. Two_spearman_per _columnpasses (raw baseline + residual headline), both ranked by|corr|descending. Reports:best_rawvsbest_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, otherwiseBORDERLINE. - 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."
- Pipeline.
- Chunk 7 follow-up (done β
scripts/tmb_resid_gp.pysynergy 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 underscripts/(biology-aware) + a small helper refactor invalidate/tmb_resid_rank.pyto 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). Bothmain()andscripts/tmb_resid_gp.pycall it, so any tweak to the within-MSI z-scoredlog1p(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 viabuild_residual_cohort();anonymise(X_named)reuses the sealed map; runsrun_v2_pipeline(M, y, objective=TMB_OBJECTIVE, seed, ...)per seed withprefilter_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 200on seeds[1, 7, 13]. Per-seed grader reveals only the winner's opaque IDs viaairgap.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_v2sees only opaque IDs; symbols cross the boundary once per seed via a boundedreveal(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 20prints the full per-seed block + ranges + verdict; scipy'sConstantInputWarningfrom degenerate programs (already floored toWORST_FITNESSby the engine) is silenced at script scope.pytest -qstill 130/130.
- Chunk 7 follow-up (done β Groups-the-engine-explored:
ascending/descending sort toggle): Frontend-only. The Groups
table (
<ModuleRankingPanel>inweb/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: newSortDir = "asc" | "desc"state next tosortKey;sortedModulesrefactored to a singlevalueOfswitch 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 onsortDirtoo β flipping direction resets page 0 and closes any expanded row.tsc --noEmitclean; 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
’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'smetric_kindconditional preserved inside the wrapper. tsc --noEmitclean; no API / airgap / engine change.
- 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
- 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 inbuild.py. - MSI label is derived, not shipped. cBioPortal has no clean MSI-H/MSS
column. We derive from
MSI_SENSOR_SCOREper the file's own documented thresholds (MSI_SENSOR_HIGH = 10.0,MSI_SENSOR_LOW = 4.0).build.pyprints this provenance every run. - Presentation lives in
app/theme.py. Palette + Altair theme. Don't override per-chart with.configure_*. MSI colours/order: import fromtheme, never inline. The H1 composition diagram (DOT inapp/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.pyderives 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.revealmay opendata/processed/_sealed_gene_map.json. The engine must never importreveal,airgap.seal, or reference the sealed-map filename β a test intests/test_airgap.pyscansengine/for those tokens. At runtime, bothdsl.Searchandengine.run_gp_pipelineassert their input matrices have columns matching^g\d+$only. Biology-aware orchestration lives inscripts/(the cohort label "MSI-H" / column name "msi_status" appears there and invalidate/, but never inengine/ordsl/). - H2 artefacts are anonymised on disk.
data/processed/h2/evolution_log.jsonanddata/processed/h2/result.jsononly ever contain opaque IDs. The reveal step happens at runtime, exactly once, via the FastAPI/revealendpoint (orvalidate.h2.reveal_winner). - Lab payloads are anonymised over the wire.
tests/test_api_airgap.pyPOSTs synthetic runs against both presets, drains the SSE stream, and asserts no gene symbol leaks through/runs/{id},/runs/{id}/result, or the stream./evaluatetranslates 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) andCorrelationObjective(direction)(continuous, e.g. TMB β random split + KFold + signed Spearman of the sum-of-set-means with the target). Each objective owns its ownprefilter_score_per_featureandpermute(y)so the GP/baseline/permutation loop stays generic. Add a new objective by subclassingengine.objectives.Objectiveand wiring it intoobjective_from_spec. - Prefilter is a strict-speed knob, not a correctness knob. When
prefilter_n=Nonethe 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()onV2Objective: 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 becomesNone. Frontend uses onefmtFit(x)helper everywhere a fitness/score is printed β falls back to "β" for non-finite, andfitnessForOrder(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.comreturns 131-byte LFS pointers). data_mutations.txtis optional β datahub is over its GitHub LFS budget for that file. Chunk 1 does not need it (TMB comes fromdata_clinical_sample.txtTMB_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_widthis deprecated in 1.58+).