"use client";

import { Fragment, useEffect, useId, useMemo, useRef, useState } from "react";
import {
  CartesianGrid,
  Line,
  LineChart,
  ResponsiveContainer,
  Tooltip,
  XAxis,
  YAxis,
} from "recharts";
import {
  API_URL,
  DatasetId,
  EvaluateResponse,
  FullRankDiagnostic,
  GeneRankRow,
  GenerationEvent,
  HPVRankDiagnostic,
  ObjectiveSpec,
  ModuleRanking,
  RankedModule,
  ReferenceMark,
  RunParams,
  RunResult,
  TMBRankDiagnostic,
  Target,
  getFullRankDiagnostic,
  getHPVRankDiagnostic,
  getRunModules,
  getRunOperatorUsage,
  getRunTransfer,
  TransferResult,
  OperatorUsage,
  getTMBRankDiagnostic,
  postEvaluate,
  postReveal,
  postRun,
} from "../lib/api";
import InfoTip from "./InfoTip";
import ProgramGraph from "./ProgramGraph";
import PopulationTiles from "./PopulationTiles";
import PasteToDraw from "./PasteToDraw";
import ParameterFlow from "./ParameterFlow";
import MMRBiologyPanel from "./MMRBiologyPanel";
import HPVBiologyPanel from "./HPVBiologyPanel";
import ParamHelp from "./ParamHelp";
import ParamHelpProvider from "./ParamHelpProvider";
import CopyButton from "./CopyButton";
import {
  REFERENCE_SETS_BY_DATASET,
  ReferenceSetKey,
  refSetsFor,
} from "../lib/referenceSets";
import { fmtFit } from "../lib/fmt";
import { ParamKey } from "./paramHelpContent";

// ---------- Tooltip copy (verbatim, never edited inline) -------------------
// Objective tooltips moved to paramHelpContent.tsx (obj_msi / obj_tmb /
// obj_survival / obj_unsupervised) so the SHORT + DETAILED + diagrams
// share the single ParamHelp infrastructure.

const PARAM_TIPS: Record<keyof RunParams, string> = {
  generations:
    "How many rounds of evolution the engine runs. Each round it " +
    "keeps the best programs and breeds variations. More rounds refine " +
    "further, but take longer.",
  population:
    "How many programs the engine tries in each round. More programs " +
    "explore more options, but the round takes longer.",
  genes_per_set:
    "The most genes a single score is allowed to use. Smaller keeps " +
    "programs simple and easy to read.",
  max_sets:
    "How many separate gene-scores a program is allowed to combine (1 " +
    "or 2). Two lets it build a small &ldquo;score made of scores.&rdquo;",
  lambda:
    "How much the engine is penalised for using more genes. Higher pushes " +
    "it toward fewer genes (simpler answers).",
  seed:
    "The starting point for the engine&rsquo;s randomness. The same seed " +
    "reproduces the exact same run; change it to see a different one.",
  prefilter_n:
    "By default the engine searches all ~20,000 genes, so nothing is " +
    "pre-excluded. Turning this on narrows to the N most promising genes " +
    "first — faster, but it can drop a real gene that only helps in " +
    "combination.",
  permutations:
    "How many times we re-run the search on deliberately scrambled " +
    "labels — a chance baseline. The real result must beat these random " +
    "runs (that&rsquo;s the permutation p).",
};

const TIPS = {
  run:
    "Starts the search with the settings above.",
  runId:
    "Just a label for this run, so its results can be fetched.",
  status:
    "Where the run is: queued → running → done.",
  // NB: fitness-curve InfoTip is now objective-aware — see
  // FITNESS_TIP_BY_TARGET below. This single string was only correct
  // for TMB and was misleading on MSI / unsup runs.
  nodes:
    "How many steps the program has.",
  genes:
    "How many genes the program uses.",
  bestVsMedian:
    "Each round, the engine keeps the best programs and breeds " +
    "variations. The solid line is the best program so far; the dashed " +
    "line is the typical one. When they meet and flatten, the " +
    "population has converged — it&rsquo;s stopped finding anything " +
    "new.",
  populationGrid:
    "Every program in this round. Darker = higher score; outlined = " +
    "kept for the next round.",
  survivedVsDiscarded:
    "Solid cards survived to breed the next generation; faded cards were " +
    "culled for scoring worse.",
  opaqueIds:
    "Anonymous stand-in labels for real genes. The engine works blind " +
    "and never sees gene names until we reveal them at the end — so it " +
    "can&rsquo;t lean on what&rsquo;s already known about them.",
  winningProgram:
    "The best program found, showing which (still-anonymous) genes it " +
    "uses and how it combines them.",
  // Held-out and permutation-p have target-aware variants (see the
  // BY_TARGET records below the TIPS block). These plain strings stay
  // for any legacy call site.
  heldOut:
    "The winner's score on patients the engine never saw during the " +
    "search (the held-out set). It tests that the result isn't just " +
    "memorising the training data.",
  permutationP:
    "How often pure chance matches this score across many random " +
    "comparisons. Small (e.g. below 0.05) = unlikely to be a fluke.",
  baseline:
    "A simple, no-frills method's score, for comparison — the evolved " +
    "program should beat it.",
  // Reference-set tip has a dataset-aware variant (see
  // REFERENCE_SET_TIP_BY_DATASET). This is the colorectal default.
  referenceSet:
    "A known gene list to check the answer against. MMR = the 4 DNA " +
    "repair / 'spell-checker' genes (MLH1, MSH2, MSH6, PMS2); " +
    "immune = immune-attack genes (CD8A, GZMA, PRF1).",
  revealEvaluateButton:
    "Unlocks the real gene names for the winning program and checks how " +
    "many match the chosen known list.",
  revealedGenes:
    "The real names behind the program's gene IDs — shown only now, " +
    "after the blind search is done.",
  overlap:
    "How many of the engine's genes appear in the known list (matches " +
    "highlighted). More overlap = it rediscovered known biology on its " +
    "own.",
  programGraph:
    "The winning program drawn as a graph: boxes are operators (verbs), " +
    "pills are data and scores. Tier-1 turns genes into a score; Tier-2 " +
    "combines scores into the prediction.",
  dslVocabulary:
    "Rate = how often the engine reaches for an operator when it " +
    "builds or mutates a program — roughly the chance, at each " +
    "eligible spot, that it picks that operator instead of a simpler " +
    "one. 0.1 ≈ 1 in 10; 0.05 ≈ 1 in 20; 0 = never. Higher means the " +
    "operator is tried in more programs (more exploration) — it does " +
    "NOT mean the operator is better; selection still decides whether " +
    "it survives into the winners. (Effect's rate is its share of the " +
    "correlation-style programs versus Associate.)",
  // Discovered-axes + post-hoc tips have dataset-aware variants below.
  // The plain strings below are the colorectal defaults.
  discoveredAxes:
    "After each unsupervised run, click \"Find next axis\" to peel the " +
    "discovered split out of the data (linear residualisation — we " +
    "subtract its signal from every gene) and search again. The chain " +
    "is a ranked, blind list of independent directions in the gene-" +
    "expression matrix — each named only afterwards by how well it " +
    "lines up with a known label. The residualisation is LINEAR, so " +
    "deeper non-linear structure may remain. The chain is in-memory " +
    "only — it resets on server restart or a fresh Run.",
  posthoc:
    "After the unsupervised search finishes, the discovered split is " +
    "compared to the named labels the engine never saw. AUROC = how " +
    "well the score sorts the two groups apart (0.5 = no relationship, " +
    "1.0 = a perfect match).",
  tmbRank:
    "Rank every gene by signed Spearman with TMB on the engine's TMB " +
    "cohort, ascending (most-negative = rank 1, since the TMB objective " +
    "rewards the most-negative association). Where each known gene " +
    "lands tells you whether the signal is even there: MMR genes near " +
    "rank 1 = findable from expression alone; high rank = the signal " +
    "isn't strongly present in this matrix.",
  rankingResult:
    "Where each KNOWN marker ranks on its own. Every gene in the " +
    "matrix is scored as a single-gene separator of the label on the " +
    "engine's TRAIN split (~20k opaque columns), sorted strongest " +
    "first; this panel pins the dataset's reference markers (p16 / " +
    "cell_cycle for HNSC; MMR / immune for CRC) so you can see how " +
    "recoverable the canonical biology is — a recovery diagnostic. " +
    "The engine's actual picks (the genetic-programming top programs, their genes, " +
    "their group scores, their confound survival) live in the Result " +
    "panel above; the after-the-fact re-scoring of every explored " +
    "group is the Coordinated modules panel below.",
  rankingHighlighted:
    "Rows pinned here are the dataset's KNOWN markers, revealed by " +
    "name. p16 / cell_cycle (HNSC) or MMR / immune (CRC). Each row is " +
    "one gene scored alone — a high rank (low %, near 1) = a strong " +
    "standalone marker; a low rank (high %, far down the list) = a " +
    "gene that only helps in combination with others. The winning " +
    "program's constituent genes live in the Result panel above as " +
    "part of its self-contained group view; they are deliberately " +
    "NOT pinned in this known-marker recovery diagnostic.",
  coherence:
    "Off: the engine rewards whatever genes separate the label best, " +
    "even a single lucky gene. On: it also rewards genes that move " +
    "together across patients — a real co-expressed program — so it " +
    "tends to surface coordinated modules (e.g. a cell-cycle program) " +
    "rather than one sharp correlate. It names no pathway; it just " +
    "prefers coordinated signals.",
  diversity:
    "Off: the population can collapse to near-clones early (the " +
    "fitness curve's best and median lines meet and go flat). On: " +
    "lowers selection pressure and injects fresh random programs " +
    "each generation, so the population keeps exploring — watch the " +
    "best-vs-median gap stay open longer. Held-out detection should " +
    "be preserved or improved, not hurt.",
  dslRates:
    "How often the engine reaches for each OPTIONAL operator while " +
    "growing a program. Each rate is in [0, 1]; setting one to 0 " +
    "removes that operator from the search. " +
    "Search is the heaviest operator (every Search node runs an " +
    "inner gene-ranking) — set its rate to 0 for faster runs. " +
    "Scalar share = the fraction of programs the engine seeds with a " +
    "Scalar root (Associate / Effect) versus a Vector root.",
  moduleRanking:
    "Why these columns exist: once the engine could detect the trait, " +
    "the real question was whether the signal came from a few genes " +
    "working together or from one strong gene, and single-gene " +
    "rankings can't tell you that. These measures judge whole groups " +
    "instead. Coherence tells a genuine co-expressed module from a " +
    "random bag of genes. Combined AUROC scores the group as a group, " +
    "on patients it never saw. Synergy separates real teamwork from " +
    "one good gene with passengers along for the ride. " +
    "Genetic-programming fitness sits beside them as the engine's own " +
    "verdict, so you can compare what the engine actually preferred " +
    "against these after-the-fact re-scores. Together they answer one " +
    "question: is the result carried by gene teamwork or by lone " +
    "strong genes? Note that the re-scores are computed after the " +
    "run, so they can rank groups differently from the engine's own " +
    "picks, and the very top re-score values are optimistically " +
    "biased because they are the best of thousands.",
  moduleSurvival:
    "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.\n\n" +
    "Site. HPV+ tumours are mostly in the oropharynx (back of the " +
    "throat), so a gene could look like an \"HPV gene\" just by " +
    "marking that location. The site check re-scores the group using " +
    "only oropharynx patients (everyone the same location). The two " +
    "numbers are full-cohort → oropharynx-only.\n\n" +
    "Purity. A tumour sample is a mix of cancer cells and immune " +
    "cells; HPV+ tumours carry more immune cells, so a gene could " +
    "look like an \"HPV gene\" just by marking immune content. The " +
    "purity check re-scores using only the \"purest\" (least-immune) " +
    "tumours. It's usually \"—\" here because those tumours include " +
    "almost no HPV+ patients, so there's nothing to test.\n\n" +
    "✓ = held up when the confounder was held constant (likely real " +
    "signal). ✗ = dropped past the tolerance (part of it was the " +
    "confounder). — = couldn't test (too few patients).",
  singleGeneRank:
    "Each gene's rank as a SINGLE-gene separator of the active target " +
    "(out of ~20,000). Low rank (near 1) = the gene separates the label " +
    "on its own — a real alternate marker. High rank = it only helps in " +
    "combination with others. AUROC for binary targets (MSI/HPV), signed " +
    "Spearman (ρ) for TMB.",
  hpvRank:
    "Rank every gene by single-gene HPV+/HPV− AUROC on the engine's " +
    "HNSC training split, descending (best separator = rank 1; " +
    "orientation-agnostic, so a gene that goes UP or DOWN in HPV+ " +
    "scores equally). Where each known HPV marker lands tells you " +
    "whether the signal is even there: CDKN2A / cell-cycle genes near " +
    "rank 1 = recoverable as single features; high rank = out-competed " +
    "by other separators (the engine may still combine them).",
};

// ---------- Top-level page -------------------------------------------------

type Status = "idle" | "running" | "done" | "error";

// Two profiles: prefilter OFF is the default (integrity over speed) so
// we bump the GP budget; prefilter ON keeps the snappier 30/150 defaults.
const DEFAULTS_NO_PREFILTER: RunParams = {
  generations: 60,
  population: 300,
  genes_per_set: 8,
  max_sets: 2,
  lambda: 0.005,
  seed: 42,
  prefilter_n: null,
  permutations: 200,
};

const DEFAULTS_WITH_PREFILTER: RunParams = {
  generations: 30,
  population: 150,
  genes_per_set: 8,
  max_sets: 2,
  lambda: 0.005,
  seed: 42,
  prefilter_n: 2000,
  permutations: 200,
};

const DEFAULT_PARAMS: RunParams = {
  generations: 60,
  population: 300,
  genes_per_set: 8,
  max_sets: 2,
  lambda: 0.005,
  seed: 42,
  prefilter_n: null,
  permutations: 200,
};

// Engine-side defaults for the editable DSL injection rates and the
// scalar-share field. Mirror engine_v2/synthesize.py:DEFAULT_RATES +
// ramped_population's default scalar_share=0.20. Mandatory operators
// (Select / Reduce / Combine; Associate is the leftover of the
// Scalar branch) get no rate — they're structural.
type DslRates = {
  split: number;
  effect: number;
  fitapply: number;
  search: number;
  scalar_share: number;
};

const DSL_DEFAULT_RATES: DslRates = {
  split: 0.10,
  effect: 0.40,
  fitapply: 0.10,
  search: 0.05,
  scalar_share: 0.20,
};

const OBJECTIVE_PRESETS: Record<Target, ObjectiveSpec> = {
  msi: { target: "msi", metric: "auroc" },
  tmb: { target: "tmb", metric: "correlation", direction: "neg" },
  none: { target: "none", metric: "structure" },
  hpv: { target: "hpv", metric: "auroc" },
};

// Short y-axis titles, calibrated to fit the rotated centred label
// inside the chart without overflowing. The actual fitness numbers are
// signed (TMB) / [0,1] (MSI) / [-1,1] (unsup) — see engine_v2's
// V2Objective.fitness_label() for the verbose form used in logs.
const FITNESS_LABEL_BY_TARGET: Record<Target, string> = {
  msi: "separation (AUROC — 0.5 coin-flip, 1.0 perfect)",
  tmb: "neg. association with TMB",
  none: "cluster separation (0–1)",
  hpv: "separation (AUROC — 0.5 coin-flip, 1.0 perfect)",
};

// Per-target "this is a real result" thresholds. The ResultVerdict at
// the top of the Result panel turns green only when the winner's
// held-out clears its target's bar AND the permutation p is < 0.05.
// Defaults are tunable; bump if real-world runs ask.
const HOLDOUT_THRESHOLD: Record<Target, number> = {
  msi: 0.75,
  tmb: 0.30,
  none: 0.30,
  hpv: 0.75,
};
const P_VALUE_SIGNIFICANT = 0.05;
const POSTHOC_MSI_ALIGN = 0.75;
const POSTHOC_HPV_ALIGN = 0.75;

// ---------- Dataset registry --------------------------------------------
// One registry entry per supported cancer / study. The engine, DSL,
// parameters, live view, population, program graph, and verdict are
// IDENTICAL across datasets — only the loader (in api/_prepare_lab_data)
// and the per-dataset config below swap.
interface ObjectiveCard {
  key: Target;
  label: string;
  sub: string;
}

interface DatasetEntry {
  label: string;
  longLabel: string;
  objectives: ObjectiveCard[];   // ordered: first entry = default target
  BiologyPanel: () => JSX.Element;
  refSetKeys: ReferenceSetKey[];
}

// The "none" (Unsupervised) objective is intentionally absent from the
// UI registry — the backend capability + tests are intact (see
// UNSUP_OBJECTIVE), it's just not surfaced as a card. The frontend
// code paths for target==="none" stay working but unreachable from
// here.
const DATASET_REGISTRY: Record<DatasetId, DatasetEntry> = {
  coadread: {
    label: "Colorectal",
    longLabel: "Colorectal (TCGA COADREAD)",
    objectives: [
      { key: "msi", label: "MSI separation", sub: "AUROC of MSI-H vs MSS" },
      { key: "tmb", label: "Mutation burden", sub: "negative correlation with TMB" },
    ],
    BiologyPanel: MMRBiologyPanel,
    refSetKeys: refSetsFor("coadread"),
  },
  hnsc: {
    label: "Head & Neck",
    longLabel: "Head & Neck (TCGA HNSC)",
    objectives: [
      { key: "hpv", label: "HPV detection", sub: "AUROC of HPV+ vs HPV−" },
    ],
    BiologyPanel: HPVBiologyPanel,
    refSetKeys: refSetsFor("hnsc"),
  },
};

// Per-target tooltip for the fitness-curve InfoTip. Replaces the old
// single TMB-centric TIPS.fitnessCurve string — now the tooltip mirrors
// the y-axis title's objective-awareness.
const FITNESS_TIP_BY_TARGET: Record<Target, string> = {
  hpv:
    "This shows how well the best program SEPARATES HPV+ from HPV− " +
    "tumours, measured by AUROC: 0.5 = coin-flip (no separation), " +
    "1.0 = perfect; higher = a cleaner split. We use AUROC because " +
    "HPV+ is only ~15% of patients, so it isn't fooled by always " +
    "guessing the majority.",
  msi:
    "This shows how well the best program SEPARATES the two subtypes " +
    "(MSI-H vs MSS), measured by AUROC: 0.5 = coin-flip (no separation), " +
    "1.0 = perfect; higher = a cleaner split. We use AUROC because MSI-H " +
    "is only ~15% of patients, so it isn't fooled by always guessing the " +
    "majority.",
  tmb:
    "This shows how strongly the program's score moves OPPOSITE to " +
    "mutation burden — low score where mutations are high (the " +
    "broken-spell-checker pattern). 0 = no relationship; higher = a " +
    "stronger opposite-direction link; the top = score and mutation " +
    "count move almost perfectly oppositely. The underlying correlation " +
    "is negative (e.g. −0.49); we plot its strength (0.49) so up = better.",
  none:
    "There is no target here. This measures how cleanly the program's " +
    "score splits patients into TWO groups: 0 ≈ no real split, 1 = two " +
    "clean, well-separated clusters. After the run we check what that " +
    "split lines up with on the named label this cohort carries — " +
    "that's the post-hoc alignment in the Result panel.",
};

// Held-out card label per objective. Binary objectives ARE an AUROC;
// say so. TMB is a (signed Spearman) correlation; unsup is silhouette.
const HELD_OUT_LABEL_BY_TARGET: Record<Target, string> = {
  msi: "Held-out AUROC",
  hpv: "Held-out AUROC",
  tmb: "Held-out (|spearman|)",
  none: "Held-out (silhouette)",
};

// Held-out tooltip per objective — same data, but the explanation of
// what 'held-out' MEANS for the active metric differs.
const HELD_OUT_TIP_BY_TARGET: Record<Target, string> = {
  msi:
    "Held-out AUROC — how well the winner sorts MSI-H apart from MSS " +
    "on patients it never saw during the search. AUROC is the chance " +
    "the score ranks a random MSI-H patient above a random MSS " +
    "patient: 0.5 = coin-flip, 1.0 = perfect. Tests the result isn't " +
    "just memorising the training data.",
  hpv:
    "How well the winner separates HPV+ from HPV− on patients it " +
    "never saw during the search. 0.5 = coin-flip, 1.0 = perfect. " +
    "This is the honest test that it didn&rsquo;t just memorise the " +
    "training patients.",
  tmb:
    "Held-out |spearman| — the strength of the negative link between " +
    "the winner's score and mutation burden on patients it never saw. " +
    "0 = no link, higher = stronger (low score tracks high TMB). " +
    "Not AUROC: TMB is a continuous label, so the metric is a " +
    "correlation. Tests the result isn't just memorising training data.",
  none:
    "Held-out silhouette — how cleanly the winner splits unseen " +
    "patients into two groups. Scale: −1 = no real split, ~1 = two " +
    "very clean groups. The engine is told nothing about which two " +
    "groups; the held-out score checks that the split it found holds " +
    "up on new patients. Not AUROC: there's no label to compare to.",
};

// Permutation-p tooltip per objective — the null shape changes for
// unsupervised (random programs, no labels to shuffle).
const PERMUTATION_P_TIP_BY_TARGET: Record<Target, string> = {
  msi:
    "How often pure chance matches this score across many runs with " +
    "the MSI labels shuffled. Small (e.g. below 0.05) = unlikely to " +
    "be a fluke.",
  hpv:
    "How often a random program would score this well by luck. Small " +
    "(e.g. below 0.05) = unlikely to be a fluke.",
  tmb:
    "How often pure chance matches this score across many runs with " +
    "the TMB values shuffled. Small (e.g. below 0.05) = unlikely to " +
    "be a fluke.",
  none:
    "How often a random gene-based program produces a split this clean " +
    "on the same patients. Small (e.g. below 0.05) = the winner is " +
    "unlikely to be a fluke (no labels are shuffled here because the " +
    "unsupervised search never saw any).",
};

// Reference-set + post-hoc + discovered-axes tips swap with the active
// dataset, since the reference vocabulary and the named labels differ
// between colorectal (MMR/immune, MSI/TMB) and head & neck (p16 /
// cell-cycle, HPV).
const REFERENCE_SET_TIP_BY_DATASET: Record<DatasetId, string> = {
  coadread:
    "A known gene list to check the answer against. MMR = the 4 DNA " +
    "repair / 'spell-checker' genes (MLH1, MSH2, MSH6, PMS2); " +
    "immune = immune-attack genes (CD8A, GZMA, PRF1).",
  hnsc:
    "A known gene list to check the answer against. p16 = CDKN2A — " +
    "the canonical HPV+ surrogate marker pathologists use; cell-cycle " +
    "= a standard set of DNA-replication / E2F-target genes that go " +
    "UP when HPV-E7 wrecks the RB brake.",
};

const POSTHOC_TIP_BY_DATASET: Record<DatasetId, string> = {
  coadread:
    "After the unsupervised search finishes, the discovered split is " +
    "compared to the MSI and TMB labels the engine never saw. AUROC " +
    "tells us whether the split tracks the MSI subtype; |spearman| " +
    "with TMB tells us whether the score moves with mutation burden. " +
    "The search itself was label-free.",
  hnsc:
    "After the unsupervised search finishes, the discovered split is " +
    "compared to the HPV label the engine never saw. AUROC tells us " +
    "whether the split tracks the HPV+ subtype: 0.5 = no relationship, " +
    "1.0 = a perfect match. The search itself was label-free.",
};

const DISCOVERED_AXES_TIP_BY_DATASET: Record<DatasetId, string> = {
  coadread:
    "After each unsupervised run, click \"Find next axis\" to peel the " +
    "discovered split out of the data (linear residualisation — we " +
    "subtract its signal from every gene) and search again. The chain " +
    "is a ranked, blind list of independent directions in the gene-" +
    "expression matrix — each named only afterwards by how well it " +
    "lines up with MSI / TMB. The residualisation is LINEAR, so " +
    "deeper non-linear structure may remain. The chain is in-memory " +
    "only — it resets on server restart or a fresh Run.",
  hnsc:
    "After each unsupervised run, click \"Find next axis\" to peel the " +
    "discovered split out of the data (linear residualisation — we " +
    "subtract its signal from every gene) and search again. The chain " +
    "is a ranked, blind list of independent directions in the gene-" +
    "expression matrix — each named only afterwards by how well it " +
    "lines up with HPV. The residualisation is LINEAR, so deeper non-" +
    "linear structure may remain. The chain is in-memory only — it " +
    "resets on server restart or a fresh Run.",
};

export default function Lab() {
  // Default to Head & Neck + HPV detection on first load. Colorectal
  // stays selectable; first-load just lands on the dataset whose
  // detection objective is the live demo of the workflow.
  const [dataset, setDatasetState] = useState<DatasetId>("hnsc");
  const [target, setTarget] = useState<Target>(
    DATASET_REGISTRY.hnsc.objectives[0].key,
  );
  const [params, setParams] = useState<RunParams>(DEFAULT_PARAMS);
  const [coherence, setCoherence] = useState<boolean>(false);
  const [diversity, setDiversity] = useState<boolean>(false);
  // Editable DSL injection rates. Only changed keys travel on the
  // wire so the default run is byte-for-byte unchanged. The DslRates
  // type is hoisted to module scope so the StabilityPanel (and any
  // other consumer) can accept it as a prop.
  const [dslRates, setDslRates] = useState<DslRates>({ ...DSL_DEFAULT_RATES });
  const [status, setStatus] = useState<Status>("idle");
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
  const [runId, setRunId] = useState<string | null>(null);
  const [generations, setGenerations] = useState<GenerationEvent[]>([]);
  const [result, setResult] = useState<RunResult | null>(null);
  const [operatorUsage, setOperatorUsage] = useState<OperatorUsage | null>(null);
  // Graph view state — single graph area, swapped by winner / candidate /
  // pasted text. The string is a program_repr that the shared parser
  // turns into a tree.
  const [graphProgramRepr, setGraphProgramRepr] = useState<string | null>(null);
  const [graphSourceLabel, setGraphSourceLabel] = useState<string>("winner");
  const [graphSelectedCandidateId, setGraphSelectedCandidateId] = useState<string | null>(null);
  // Textarea text lives at the Lab level so tile clicks AND the paste tool
  // share the same controlled string.
  const [pasteText, setPasteText] = useState<string>("");
  const [revealData, setRevealData] = useState<EvaluateResponse | null>(null);
  // Iterative-unsupervised discovery: the ordered chain of axes
  // discovered so far (Axis 1, Axis 2, …). Each new unsup result —
  // whether it's the first run or a "Find next axis" follow-up — is
  // appended. A fresh Run resets the chain.
  const [axes, setAxes] = useState<{ run_id: string; result: RunResult }[]>(
    [],
  );
  const esRef = useRef<EventSource | null>(null);

  useEffect(() => {
    return () => {
      esRef.current?.close();
    };
  }, []);

  // When a fresh result arrives, default the graph to the winning program.
  useEffect(() => {
    if (result) {
      setGraphProgramRepr(result.winning.program_repr);
      setPasteText(result.winning.program_repr);
      setGraphSourceLabel("winner");
      setGraphSelectedCandidateId(null);
    }
  }, [result]);

  // Switching the cancer resets the chain + per-dataset state and
  // snaps the objective to the new dataset's default (first card) so
  // the request body never carries a stale (dataset, target) combo.
  function setDataset(next: DatasetId) {
    if (next === dataset) return;
    setDatasetState(next);
    const firstObjective = DATASET_REGISTRY[next].objectives[0].key;
    setTarget(firstObjective);
    setResult(null);
    setOperatorUsage(null);
    setGenerations([]);
    setRevealData(null);
    setRunId(null);
    setAxes([]);
    setGraphProgramRepr(null);
    setPasteText("");
    setGraphSelectedCandidateId(null);
  }

  async function launchRun(
    opts: { residualize_against?: string[] } = {},
  ) {
    const chained =
      !!opts.residualize_against && opts.residualize_against.length > 0;
    setStatus("running");
    setErrorMsg(null);
    setGenerations([]);
    setResult(null);
    setOperatorUsage(null);
    setRunId(null);
    setRevealData(null);
    setGraphProgramRepr(null);
    setPasteText("");
    setGraphSelectedCandidateId(null);
    // Fresh runs reset the chain; "Find next axis" preserves it.
    if (!chained) setAxes([]);
    try {
      // Only the keys the user actually changed travel on the wire —
      // an untouched DSL config reproduces the engine's defaults
      // byte-for-byte.
      const ratesDiff: Record<string, number> = {};
      (Object.keys(DSL_DEFAULT_RATES) as (keyof DslRates)[]).forEach((k) => {
        if (dslRates[k] !== DSL_DEFAULT_RATES[k]) {
          ratesDiff[k] = dslRates[k];
        }
      });
      const body: Parameters<typeof postRun>[0] = {
        objective_spec: OBJECTIVE_PRESETS[target],
        params,
        engine: "v2",
        dataset,
        coherence,
        diversity,
      };
      if (Object.keys(ratesDiff).length > 0) body.rates_override = ratesDiff;
      if (chained) body.residualize_against = opts.residualize_against;
      const { run_id } = await postRun(body);
      setRunId(run_id);
      const es = new EventSource(`${API_URL}/runs/${run_id}/stream`);
      esRef.current?.close();
      esRef.current = es;
      es.addEventListener("generation", (ev) => {
        const data = JSON.parse((ev as MessageEvent).data) as GenerationEvent;
        setGenerations((prev) => [...prev, data]);
      });
      es.addEventListener("done", (ev) => {
        const data = JSON.parse((ev as MessageEvent).data) as RunResult;
        setResult(data);
        setStatus("done");
        // Peel-off chain: append every result so the user sees a
        // ranked stack of axes — supervised objectives now too.
        setAxes((prev) => [...prev, { run_id, result: data }]);
        // Fetch the operator-usage payload now that the run is done.
        // Cached server-side; opaque-safe (keywords + counts only).
        // Non-fatal — the DSL vocabulary tiles just render without
        // counts on failure.
        getRunOperatorUsage(run_id)
          .then((u) => setOperatorUsage(u))
          .catch(() => setOperatorUsage(null));
        es.close();
      });
      es.addEventListener("error", (ev) => {
        try {
          const data = JSON.parse((ev as MessageEvent).data) as {
            detail?: string;
          };
          setErrorMsg(data.detail ?? "stream error");
        } catch {
          setErrorMsg("stream error");
        }
        setStatus("error");
        es.close();
      });
      es.onerror = () => {
        if (status === "running") {
          // Only flag if we haven't received a clean done already.
          setErrorMsg((m) => m ?? "lost connection to /stream");
          setStatus((s) => (s === "running" ? "error" : s));
          es.close();
        }
      };
    } catch (e) {
      setErrorMsg(e instanceof Error ? e.message : String(e));
      setStatus("error");
    }
  }

  async function start() {
    await launchRun();
  }

  async function findNextAxis() {
    await launchRun({
      residualize_against: axes.map((a) => a.run_id),
    });
  }

  const fitnessLabel = FITNESS_LABEL_BY_TARGET[target];

  return (
    <ParamHelpProvider>
    <main className="mx-auto max-w-5xl px-8 py-12">
      <header className="mb-10">
        <h1 className="text-3xl font-semibold tracking-tight text-ink">
          OncoDSL Lab
        </h1>
        <p className="mt-2 text-sm text-muted">
          The engine hunts for a gene-expression pattern that predicts a
          cancer trait — blind: gene names are hidden as codes so it
          can&rsquo;t lean on what we already know. You set the goal; it
          evolves a small program to hit it; then we reveal the genes it
          chose and check them against known biology.
        </p>
      </header>

      <div className="space-y-8">
        <CancerSelector dataset={dataset} onDataset={setDataset} />
        <ObjectiveBuilder
          dataset={dataset}
          target={target}
          onTarget={setTarget}
        />
        <BiologyPanelForDataset dataset={dataset} />
        <ParamsControls
          params={params}
          onParams={setParams}
          coherence={coherence}
          onCoherence={setCoherence}
          diversity={diversity}
          onDiversity={setDiversity}
        />

        {/*
          DSL vocabulary moved ABOVE the Run section now that its rate
          inputs are interactive — they're config the user edits
          before pressing Run.
        */}
        <SectionCard
          title="DSL vocabulary"
          titleTip={TIPS.dslVocabulary}
          subtitle="The building blocks a program can use. The engine combines them freely; you can nudge how often it reaches for the optional ones."
        >
          <DSLVocabularyTiles
            usage={operatorUsage}
            rates={dslRates}
            onRates={setDslRates}
          />
          {operatorUsage && (
            <p className="mt-3 text-[11px] italic text-muted">
              How often the engine reached for each building block across
              the whole search. Select and Reduce are the backbone; the
              rest show up only where they helped.
            </p>
          )}
        </SectionCard>

        <RunBar status={status} onRun={start} runId={runId} error={errorMsg} />
        <LiveView
          generations={generations}
          fitnessLabel={fitnessLabel}
          target={target}
          status={status}
        />

        {runId && status !== "idle" && (
          <SectionCard
            title="Population"
            titleTip={TIPS.populationGrid}
            subtitle="Every candidate this generation — fitness shown by tint, survivors outlined in accent."
          >
            <PopulationTiles
              runId={runId}
              generationsPersisted={generations.length}
              selectedId={graphSelectedCandidateId}
              onSelect={(c) => {
                setGraphProgramRepr(c.program_repr);
                setPasteText(c.program_repr);
                setGraphSourceLabel(`candidate ${c.id}`);
                setGraphSelectedCandidateId(c.id);
              }}
            />
          </SectionCard>
        )}

        <SectionCard
          title="Program graph"
          titleTip={TIPS.programGraph}
          subtitle={
            graphProgramRepr
              ? `Showing ${graphSourceLabel}. ${
                  revealData
                    ? "Gene codes replaced with real gene names; textbook markers highlighted."
                    : "Before reveal: only the engine&rsquo;s anonymous codes are shown."
                }`
              : "The winning program, drawn as a tree: pick genes → turn them into a score → out comes an HPV+ probability."
          }
        >
          {graphProgramRepr ? (
            <ProgramGraph
              programRepr={graphProgramRepr}
              outputLabel={
                target === "msi"
                  ? "MSI-H probability"
                  : target === "hpv"
                    ? "HPV+ probability"
                    : target === "tmb"
                      ? "TMB association"
                      : "cluster score"
              }
              reveal={buildRevealMap(revealData)}
            />
          ) : (
            <div className="rounded-md border border-dashed border-border bg-bg p-8 text-center text-xs text-muted">
              No program drawn yet.
            </div>
          )}
          <div className="mt-4">
            <PasteToDraw
              text={pasteText}
              onTextChange={setPasteText}
              onDraw={(repr, label) => {
                setGraphProgramRepr(repr);
                setGraphSourceLabel(label);
                setGraphSelectedCandidateId(null);
              }}
            />
          </div>
        </SectionCard>

        {result && (
          <ResultPanel
            result={result}
            dataset={dataset}
            target={target}
            runId={runId}
            coherence={coherence}
            diversity={diversity}
            params={params}
            dslRates={dslRates}
          />
        )}
        {result && target !== "none" && (
          <RankingResult
            result={result}
            dataset={dataset}
            target={target}
            onRevealed={setRevealData}
          />
        )}
        {result && target !== "none" && coherence && runId && (
          <ModuleRankingPanel
            runId={runId}
            dataset={dataset}
            target={target}
            winnerGeneIds={result.winning.gene_ids}
          />
        )}
        {result && target === "none" && (
          <Evaluator
            geneIds={result.winning.gene_ids}
            fitnessLabel={fitnessLabel}
            dataset={dataset}
            target={target}
            onRevealed={setRevealData}
          />
        )}
        {/*
          DiscoveredAxes (peel-off chain) is no longer rendered.
          Peel-off only makes sense for the unsupervised objective —
          on supervised HPV / MSI / TMB runs "Find next axis"
          re-finds the same signal and the "aligns with MSI / TMB"
          framing is nonsensical. The unsupervised objective isn't
          a UI objective anymore, so this never fires. Gate kept as
          `target === "none"` for documentation; the component
          definition + backend chain remain untouched in case we
          want to surface it again behind a developer flag.
        */}
        {target === "none" && axes.length > 0 && (
          <DiscoveredAxes
            axes={axes}
            status={status}
            dataset={dataset}
            onFindNext={findNextAxis}
          />
        )}
      </div>
    </main>
    </ParamHelpProvider>
  );
}

// ---------------------------------------------------------------------------
// Iterative unsupervised discovery: the "peel off axes" stack.
// ---------------------------------------------------------------------------

function DiscoveredAxes({
  axes,
  status,
  dataset,
  onFindNext,
}: {
  axes: { run_id: string; result: RunResult }[];
  status: Status;
  dataset: DatasetId;
  onFindNext: () => void;
}) {
  const busy = status === "running";
  return (
    <SectionCard
      title="Discovered axes"
      subtitle="Each axis is what the engine found after residualising out every earlier axis — an orthogonal direction in the data."
      titleTip={DISCOVERED_AXES_TIP_BY_DATASET[dataset]}
    >
      <ol className="space-y-3">
        {axes.map((a, i) => (
          <AxisCard key={a.run_id} index={i + 1} result={a.result} />
        ))}
      </ol>
      <div className="mt-4 flex items-center gap-3">
        <button
          type="button"
          onClick={onFindNext}
          disabled={busy}
          className={[
            "rounded-md px-4 py-1.5 text-sm font-medium",
            busy
              ? "cursor-not-allowed bg-muted text-card"
              : "bg-accent text-card hover:bg-ink",
          ].join(" ")}
        >
          {busy ? "Searching…" : "Find next axis →"}
        </button>
        <span className="text-xs text-muted">
          Chains within this session only. Start a fresh Run to reset.
        </span>
      </div>
    </SectionCard>
  );
}

function AxisCard({ index, result }: { index: number; result: RunResult }) {
  const w = result.winning;
  const target = result.objective_spec.target as Target;
  const threshold = HOLDOUT_THRESHOLD[target];
  const generalises =
    Number.isFinite(w.holdout_score) && w.holdout_score >= threshold;
  const significant =
    Number.isFinite(w.permutation_p) && w.permutation_p < P_VALUE_SIGNIFICANT;
  const success = generalises && significant;
  const posthoc = result.posthoc;
  const msi = posthoc?.msi_auroc;
  const tmb = posthoc?.tmb_abs_spearman;

  return (
    <li
      className={[
        "rounded-md border px-4 py-3",
        success
          ? "border-accent/40 bg-accent/5"
          : "border-border bg-card",
      ].join(" ")}
    >
      <div className="flex items-baseline justify-between gap-3">
        <div className="text-sm font-semibold text-ink">Axis {index}</div>
        <div className="text-[11px] text-muted">
          held-out {fmtFit(w.holdout_score, 3)} · p {fmtFit(w.permutation_p, 4)}
          {!success && (
            <span className="ml-2 italic">(not above the verdict threshold)</span>
          )}
        </div>
      </div>
      <div className="mt-1.5 break-all font-mono text-[11px] text-muted">
        {w.gene_ids.length > 0 ? w.gene_ids.join(", ") : "(no genes)"}
      </div>
      <div className="mt-2 text-[12px] text-ink">
        Aligns with{" "}
        <span className="font-medium">MSI</span>{" "}
        {msi != null && Number.isFinite(msi) ? (
          <>
            at AUROC <span className="font-mono">{fmtFit(msi, 3)}</span>
            {msi >= POSTHOC_MSI_ALIGN && (
              <span className="ml-1 text-accent">— matches the subtype</span>
            )}
          </>
        ) : (
          <span className="italic text-muted">— (too few labels)</span>
        )}
        ; <span className="font-medium">TMB</span> |spearman|{" "}
        {tmb != null && Number.isFinite(tmb) ? (
          <span className="font-mono">{fmtFit(tmb, 3)}</span>
        ) : (
          <span className="italic text-muted">—</span>
        )}
        .
      </div>
    </li>
  );
}

// Optional-operator rate-input keys recognised by the DSL panel.
// Mandatory operators (Select / Reduce / Combine; Associate is the
// leftover of the Scalar branch) have no editable rate.
type DslRateKey = "split" | "effect" | "fitapply" | "search";

function DSLVerbCard({
  name,
  hint,
  usage,
  maxUses,
  nCandidates,
  rateKey,
  rateValue,
  rateDefault,
  onRate,
  structuralNote,
}: {
  name: string;
  hint: string;
  usage: { total_uses: number; programs_using: number } | null;
  maxUses: number;
  nCandidates: number;
  // When `rateKey` is set the tile renders a rate input; otherwise
  // it shows `structuralNote` (always-on operators).
  rateKey?: DslRateKey;
  rateValue?: number;
  rateDefault?: number;
  onRate?: (k: DslRateKey, v: number) => void;
  structuralNote?: string;
}) {
  const showUsage = usage != null && nCandidates > 0;
  const barFrac =
    showUsage && maxUses > 0 ? usage!.total_uses / maxUses : 0;
  const sharePct =
    showUsage && nCandidates > 0
      ? Math.round((usage!.programs_using / nCandidates) * 100)
      : 0;
  const showRate = rateKey != null && onRate != null;
  const isDefault =
    showRate && rateValue != null && rateDefault != null
      && Math.abs(rateValue - rateDefault) < 1e-9;
  return (
    <div className="rounded-md border border-border bg-bg px-3 py-2">
      <div className="flex items-baseline justify-between gap-2">
        <div className="font-mono text-[13px] font-semibold text-accent">
          {name}
        </div>
        {showRate ? (
          <label className="flex items-center gap-1 text-[10.5px] text-muted">
            <span className="inline-flex items-center">
              rate
              <InfoTip
                text={
                  "How often the engine reaches for this operator while " +
                  "exploring (per eligible spot). 0.1 ≈ 1 in 10; 0 = " +
                  "never. Not a measure of quality — selection still " +
                  "decides what survives. Default " +
                  rateDefault!.toFixed(2) + "."
                }
                label={`About the ${name} rate`}
              />
            </span>
            <input
              type="number"
              min={0}
              max={1}
              step={0.05}
              value={rateValue}
              onChange={(e) => {
                const raw = parseFloat(e.target.value);
                const clamped = Number.isFinite(raw)
                  ? Math.max(0, Math.min(1, raw))
                  : 0;
                onRate!(rateKey!, clamped);
              }}
              className="w-14 rounded border border-border bg-card px-1.5 py-0.5 font-mono text-[11px] text-ink focus:border-accent focus:outline-none"
              aria-label={`${name} injection rate`}
            />
            {!isDefault && (
              <span
                title={`default ${rateDefault!.toFixed(2)}`}
                className="text-[10px] text-accent"
                aria-hidden
              >
                ●
              </span>
            )}
          </label>
        ) : structuralNote ? (
          <span className="text-[10px] text-muted">{structuralNote}</span>
        ) : null}
      </div>
      <div className="mt-0.5 text-[11px] text-muted">{hint}</div>
      {showUsage && (
        <>
          <div className="mt-1.5 text-[11px] text-ink">
            used{" "}
            <span className="font-mono font-semibold">
              {usage!.total_uses.toLocaleString()}
            </span>
            × — in{" "}
            <span className="font-mono font-semibold">
              {sharePct}%
            </span>{" "}
            of programs
          </div>
          <div
            aria-hidden
            className="mt-1 h-1 w-full overflow-hidden rounded-sm bg-card"
          >
            <div
              style={{
                width: `${Math.max(0, Math.min(100, barFrac * 100))}%`,
                height: "100%",
                background: usage!.total_uses > 0 ? "#3A6B7E" : "#C9CCC4",
                transition: "width 200ms ease",
              }}
            />
          </div>
        </>
      )}
    </div>
  );
}

function DSLVocabularyTiles({
  usage,
  rates,
  onRates,
}: {
  usage: OperatorUsage | null;
  rates: {
    split: number; effect: number; fitapply: number; search: number;
    scalar_share: number;
  };
  onRates: React.Dispatch<React.SetStateAction<typeof rates>>;
}) {
  // Mandatory (structural) operators come first; the engine always
  // closes Vector to Reduce(Select(M, …), agg) and Combine glues
  // scores together, so they have no rate.
  const STRUCTURAL: { name: string; hint: string }[] = [
    { name: "Select",     hint: "Pick specific genes." },
    { name: "Reduce",     hint: "Combine those genes into one score per patient (average, max, etc.)." },
    { name: "Combine",    hint: "Merge two scores into one." },
    { name: "Associate",  hint: "Ask how strongly the score correlates with the target — the default of the two correlation-style options." },
  ];
  const OPTIONAL: { name: string; hint: string; key: DslRateKey }[] = [
    { name: "Split",     key: "split",    hint: "Split patients into two subgroups, score each, then bring them back together." },
    { name: "Effect",    key: "effect",   hint: "Same correlation as Associate, but after removing the effect of things like stage, age, sex, and race." },
    { name: "Fit/Apply", key: "fitapply", hint: "Fit a small model that maps the score to the target, then use it." },
    { name: "Search",    key: "search",   hint: "Runs a tiny gene-ranker inside the program (up to 4 genes, from at most 200 candidates). Rate 0 turns it off." },
  ];
  const byName = new Map<string, OperatorUsage["operators"][number]>();
  if (usage) for (const op of usage.operators) byName.set(op.name, op);
  const maxUses = usage
    ? usage.operators.reduce((m, op) => Math.max(m, op.total_uses), 0)
    : 0;
  const nCandidates = usage?.n_candidates ?? 0;

  function setRate(k: DslRateKey | "scalar_share", v: number) {
    onRates((prev) => ({ ...prev, [k]: v }));
  }

  // For the "Set all equal" button: split the (default) sum of the
  // four optional rates evenly across them so the average grammar
  // mix stays near the defaults. (Default sum = 0.10 + 0.40 + 0.10
  // + 0.05 = 0.65 → ~0.16 each.)
  const equalEach =
    (DSL_DEFAULT_RATES.split + DSL_DEFAULT_RATES.effect
      + DSL_DEFAULT_RATES.fitapply + DSL_DEFAULT_RATES.search) / 4;

  const isDefault = (
    rates.split === DSL_DEFAULT_RATES.split
    && rates.effect === DSL_DEFAULT_RATES.effect
    && rates.fitapply === DSL_DEFAULT_RATES.fitapply
    && rates.search === DSL_DEFAULT_RATES.search
    && rates.scalar_share === DSL_DEFAULT_RATES.scalar_share
  );

  return (
    <>
      <div className="grid grid-cols-1 gap-2 text-xs text-ink sm:grid-cols-3">
        {STRUCTURAL.map((v) => (
          <DSLVerbCard
            key={v.name}
            name={v.name}
            hint={v.hint}
            usage={byName.get(v.name) ?? null}
            maxUses={maxUses}
            nCandidates={nCandidates}
            structuralNote="always available"
          />
        ))}
        {OPTIONAL.map((v) => (
          <DSLVerbCard
            key={v.name}
            name={v.name}
            hint={v.hint}
            usage={byName.get(v.name) ?? null}
            maxUses={maxUses}
            nCandidates={nCandidates}
            rateKey={v.key}
            rateValue={rates[v.key]}
            rateDefault={DSL_DEFAULT_RATES[v.key]}
            onRate={(k, val) => setRate(k, val)}
          />
        ))}
      </div>

      <div className="mt-3 flex flex-wrap items-center gap-3 rounded-md border border-border bg-bg px-3 py-2">
        <label className="flex items-center gap-1.5 text-[11.5px] text-ink">
          <span className="font-medium">Scalar share</span>
          <input
            type="number"
            min={0}
            max={1}
            step={0.05}
            value={rates.scalar_share}
            onChange={(e) => {
              const raw = parseFloat(e.target.value);
              const clamped = Number.isFinite(raw)
                ? Math.max(0, Math.min(1, raw))
                : 0;
              setRate("scalar_share", clamped);
            }}
            className="w-16 rounded border border-border bg-card px-1.5 py-0.5 font-mono text-[11.5px] text-ink focus:border-accent focus:outline-none"
            aria-label="Scalar share"
          />
          <span className="text-[10.5px] text-muted">
            (default {DSL_DEFAULT_RATES.scalar_share.toFixed(2)})
          </span>
          <InfoTip
            text={
              "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 — so it controls " +
              "how often the engine even tries Associate / Effect. " +
              "Default 0.20 ≈ 1 in 5; raise it to see them used more, " +
              "set 0 to switch them off."
            }
            label="About the scalar share"
          />
        </label>
        <span className="text-[10.5px] text-muted">
          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.
        </span>
        <span className="ml-auto flex items-center gap-2">
          <InfoTip text={TIPS.dslRates} label="About the DSL rates" />
          <button
            type="button"
            onClick={() => onRates({ ...DSL_DEFAULT_RATES })}
            disabled={isDefault}
            className="rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-ink hover:border-accent/40 disabled:opacity-40"
          >
            Reset to defaults
          </button>
          <button
            type="button"
            onClick={() =>
              onRates({
                split: equalEach,
                effect: equalEach,
                fitapply: equalEach,
                search: equalEach,
                scalar_share: rates.scalar_share,
              })
            }
            className="rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-ink hover:border-accent/40"
            title="Set every optional operator's rate to the same value (the neutral-prior experiment)."
          >
            Set all equal
          </button>
        </span>
      </div>
    </>
  );
}

function buildRevealMap(
  data: EvaluateResponse | null,
): Record<string, { symbol: string; matched: boolean }> | undefined {
  if (!data) return undefined;
  const out: Record<string, { symbol: string; matched: boolean }> = {};
  for (const row of data.revealed) {
    out[row.id] = { symbol: row.symbol, matched: row.matched };
  }
  return out;
}

// ---------- Building blocks ------------------------------------------------

function SectionCard({
  title,
  titleTip,
  subtitle,
  children,
}: {
  title: string;
  titleTip?: string;
  subtitle?: string;
  children: React.ReactNode;
}) {
  return (
    <section className="rounded-xl border border-border bg-card p-6 shadow-[0_1px_0_0_rgba(35,48,58,0.02)]">
      <div className="mb-4">
        <h2 className="flex items-center text-base font-semibold text-ink">
          {title}
          {titleTip && <InfoTip text={titleTip} label={`About ${title}`} />}
        </h2>
        {subtitle && (
          <p className="mt-1 text-xs text-muted">{subtitle}</p>
        )}
      </div>
      {children}
    </section>
  );
}

// Top-level cancer / problem selector. Mirrors the segmented style of
// the Objective control. Swapping a cancer swaps the objective options
// + biology panel + reference sets via the dataset registry; the engine
// underneath is identical.
function CancerSelector({
  dataset,
  onDataset,
}: {
  dataset: DatasetId;
  onDataset: (d: DatasetId) => void;
}) {
  const entries = Object.entries(DATASET_REGISTRY) as [DatasetId, DatasetEntry][];
  return (
    <SectionCard
      title="Cancer / problem"
      subtitle="Which cancer to study. Picking one swaps the biology, the goals, and the reference genes below — the engine itself stays the same."
    >
      <div
        className={
          "grid grid-cols-1 gap-3 " +
          (entries.length >= 3 ? "sm:grid-cols-3" : "sm:grid-cols-2")
        }
      >
        {entries.map(([id, entry]) => {
          const selected = id === dataset;
          return (
            <button
              key={id}
              type="button"
              onClick={() => onDataset(id)}
              aria-pressed={selected}
              className={[
                "w-full rounded-lg border px-4 py-3 text-left text-sm transition-colors",
                selected
                  ? "border-accent bg-accent text-card"
                  : "border-border bg-card text-ink hover:border-accent/40",
              ].join(" ")}
            >
              <div className="font-medium">{entry.label}</div>
              <div
                className={
                  selected ? "text-xs text-card/80" : "text-xs text-muted"
                }
              >
                {/* `longLabel` is e.g. "Colorectal (TCGA COADREAD)";
                    strip the leading short label so we don't repeat
                    "Colorectal", keep the parenthesised cohort tag
                    intact (the old `/^[(]|[)]$/g` regex was matching
                    the trailing ')' and stripping it). */}
                {entry.longLabel
                  .replace(new RegExp(`^${entry.label}\\s*`), "")
                  .trim()}
              </div>
            </button>
          );
        })}
      </div>
    </SectionCard>
  );
}

function BiologyPanelForDataset({ dataset }: { dataset: DatasetId }) {
  const Panel = DATASET_REGISTRY[dataset].BiologyPanel;
  return <Panel />;
}

function ObjectiveBuilder({
  dataset,
  target,
  onTarget,
}: {
  dataset: DatasetId;
  target: Target;
  onTarget: (t: Target) => void;
}) {
  const options = DATASET_REGISTRY[dataset].objectives;
  const cols = options.length >= 3 ? "sm:grid-cols-3" : "sm:grid-cols-2";

  return (
    <SectionCard
      title="Objective"
      subtitle="What you&rsquo;re asking the engine to find."
    >
      <div className={"grid grid-cols-1 gap-3 " + cols}>
        {options.map((o) => {
          const selected = o.key === target;
          const paramKey = (
            o.key === "none" ? "obj_unsupervised" : `obj_${o.key}`
          ) as ParamKey;
          return (
            <div key={o.key} className="relative">
              <button
                type="button"
                onClick={() => onTarget(o.key)}
                className={[
                  "w-full rounded-lg border px-4 py-3 pr-8 text-left text-sm transition-colors",
                  selected
                    ? "border-accent bg-accent text-card"
                    : "border-border bg-card text-ink hover:border-accent/40",
                ].join(" ")}
              >
                <div className="font-medium">{o.label}</div>
                <div
                  className={
                    selected
                      ? "text-xs text-card/80"
                      : "text-xs text-muted"
                  }
                >
                  {o.sub}
                </div>
              </button>
              <span className="absolute right-2 top-2">
                <ParamHelp paramKey={paramKey} label={`About ${o.label}`} />
              </span>
            </div>
          );
        })}
      </div>
    </SectionCard>
  );
}

type NumField = {
  key: Exclude<keyof RunParams, "prefilter_n">;
  label: string;
  min: number;
  max: number;
  step: number;
};

function ParamsControls({
  params,
  onParams,
  coherence,
  onCoherence,
  diversity,
  onDiversity,
}: {
  params: RunParams;
  onParams: (p: RunParams) => void;
  coherence: boolean;
  onCoherence: (c: boolean) => void;
  diversity: boolean;
  onDiversity: (d: boolean) => void;
}) {
  const fields: NumField[] = [
    { key: "generations", label: "Generations", min: 2, max: 1000, step: 1 },
    { key: "population", label: "Population", min: 10, max: 3000, step: 10 },
    { key: "genes_per_set", label: "Genes / set", min: 2, max: 8, step: 1 },
    { key: "max_sets", label: "Max sets", min: 1, max: 2, step: 1 },
    { key: "lambda", label: "λ (size penalty)", min: 0, max: 0.05, step: 0.001 },
    { key: "seed", label: "Seed", min: 0, max: 9999, step: 1 },
    { key: "permutations", label: "Permutations", min: 10, max: 500, step: 10 },
  ];
  const prefilterOff = params.prefilter_n === null;

  function setPrefilterOff() {
    onParams({ ...DEFAULTS_NO_PREFILTER, seed: params.seed });
  }
  function setPrefilterOn() {
    onParams({ ...DEFAULTS_WITH_PREFILTER, seed: params.seed });
  }

  return (
    <SectionCard
      title="Parameters"
      subtitle="How hard the engine searches. Bigger numbers explore more but run slower; results shift with the random seed, so try a few and compare."
    >
      <ParameterFlow />
      {prefilterOff && (
        <p className="-mt-2 mb-4 text-xs text-muted">
          Searching all genes is a bigger space — higher population/generations
          help, and results vary by seed, so try a few seeds and compare.
        </p>
      )}
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
        {fields.map((f) => (
          <label key={f.key} className="flex flex-col text-xs text-muted">
            <span className="mb-1 flex items-center font-medium text-ink">
              {f.label}
              <ParamHelp paramKey={f.key as ParamKey} label={`About ${f.label}`} />
            </span>
            <input
              type="number"
              min={f.min}
              max={f.max}
              step={f.step}
              value={params[f.key] as number}
              onChange={(e) =>
                onParams({
                  ...params,
                  [f.key]:
                    f.step < 1
                      ? Number.parseFloat(e.target.value)
                      : Number.parseInt(e.target.value, 10),
                })
              }
              className="rounded-md border border-border bg-white px-3 py-2 text-sm text-ink focus:border-accent focus:outline-none"
            />
          </label>
        ))}

        <div className="flex flex-col text-xs text-muted">
          <span className="mb-1 flex items-center font-medium text-ink">
            Prefilter top-N
            <ParamHelp paramKey="prefilter_n" label="About Prefilter top-N" />
          </span>
          <div className="flex overflow-hidden rounded-md border border-border bg-white text-sm">
            <button
              type="button"
              onClick={setPrefilterOff}
              className={[
                "flex-1 px-2 py-2 text-xs transition-colors",
                prefilterOff
                  ? "bg-ink text-card"
                  : "text-ink hover:bg-bg",
              ].join(" ")}
            >
              Off (all genes)
            </button>
            <button
              type="button"
              onClick={setPrefilterOn}
              className={[
                "flex-1 px-2 py-2 text-xs transition-colors",
                !prefilterOff
                  ? "bg-ink text-card"
                  : "text-ink hover:bg-bg",
              ].join(" ")}
            >
              On
            </button>
          </div>
          {!prefilterOff && (
            <input
              type="number"
              min={50}
              max={20000}
              step={50}
              value={params.prefilter_n as number}
              onChange={(e) =>
                onParams({
                  ...params,
                  prefilter_n: Number.parseInt(e.target.value, 10),
                })
              }
              className="mt-2 rounded-md border border-border bg-white px-3 py-2 text-sm text-ink focus:border-accent focus:outline-none"
            />
          )}
        </div>
      </div>
      <div className="mt-5 flex items-start gap-2 rounded-md border border-border bg-bg p-3">
        <input
          id="coherence-toggle"
          type="checkbox"
          checked={coherence}
          onChange={(e) => onCoherence(e.target.checked)}
          className="mt-0.5 h-4 w-4 cursor-pointer accent-accent"
        />
        <label
          htmlFor="coherence-toggle"
          className="cursor-pointer select-none text-xs leading-snug text-ink"
        >
          <span className="flex items-center font-medium">
            Prefer coordinated gene modules
            <InfoTip text={TIPS.coherence} label="About the coherence prior" />
          </span>
          <span className="block text-muted">
            Default off. Adds a small bonus to programs whose genes move
            together across patients, so the engine tends to surface
            coordinated modules instead of one lucky correlate.
          </span>
        </label>
      </div>
      <div className="mt-3 flex items-start gap-2 rounded-md border border-border bg-bg p-3">
        <input
          id="diversity-toggle"
          type="checkbox"
          checked={diversity}
          onChange={(e) => onDiversity(e.target.checked)}
          className="mt-0.5 h-4 w-4 cursor-pointer accent-accent"
        />
        <label
          htmlFor="diversity-toggle"
          className="cursor-pointer select-none text-xs leading-snug text-ink"
        >
          <span className="flex items-center font-medium">
            Maintain diversity
            <InfoTip text={TIPS.diversity} label="About the diversity knob" />
          </span>
          <span className="block text-muted">
            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.
          </span>
        </label>
      </div>
      {/*
        The Search-operator toggle moved into the DSL vocabulary
        panel as an editable rate (rate = 0 means disabled). See
        <DSLVocabularyTiles> below.
      */}
    </SectionCard>
  );
}

function RunBar({
  status,
  onRun,
  runId,
  error,
}: {
  status: Status;
  onRun: () => void;
  runId: string | null;
  error: string | null;
}) {
  const isRunning = status === "running";
  return (
    <SectionCard
      title="Run"
      titleTip={TIPS.run}
      subtitle="Start the search."
    >
      <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
        <button
          type="button"
          onClick={onRun}
          disabled={isRunning}
          className={[
            "rounded-md px-5 py-2 text-sm font-medium transition-colors",
            isRunning
              ? "cursor-not-allowed bg-muted text-card"
              : "bg-accent text-card hover:bg-ink",
          ].join(" ")}
        >
          {isRunning ? "Running…" : "Run"}
        </button>
        <div className="flex items-center text-xs text-muted">
          {runId && (
            <span className="flex items-center">
              <span className="flex items-center">
                run id
                <InfoTip text={TIPS.runId} label="About run id" />
              </span>
              <code className="ml-1 text-ink">{runId}</code>
              <span className="mx-1">·</span>
              <span className="flex items-center">
                status
                <InfoTip text={TIPS.status} label="About status" />
              </span>
              <span className="ml-1 text-ink">{status}</span>
            </span>
          )}
        </div>
      </div>
      {error && (
        <p className="mt-3 rounded-md border border-highlight/40 bg-highlight/10 px-3 py-2 text-xs text-highlight">
          {error}
        </p>
      )}
    </SectionCard>
  );
}

// Centered Y-axis label: rotated <text> placed at the plot's vertical
// midpoint via the YAxis viewBox. Default Recharts label sits at the top
// of the axis with `position="insideLeft"`; this puts it where readers
// expect — beside the middle of the axis.
function CenteredYAxisLabel({
  value,
  viewBox,
}: {
  value: string;
  // Recharts injects viewBox automatically when the component is passed
  // as a label.
  viewBox?: { x: number; y: number; width: number; height: number };
}) {
  if (!viewBox) return null;
  const cx = viewBox.x - 4;
  const cy = viewBox.y + viewBox.height / 2;
  return (
    <text
      x={cx}
      y={cy}
      transform={`rotate(-90, ${cx}, ${cy})`}
      textAnchor="middle"
      fill="#6E7F8C"
      fontSize={11}
    >
      {value}
    </text>
  );
}

// Custom legend rendered OUTSIDE the chart SVG so it lives on its own
// row, below the X-axis title. Swatches mirror the line styles exactly:
// solid accent for best, dashed muted for median.
function ChartLegend() {
  return (
    <div
      className="mt-2 flex items-center justify-center gap-6 text-xs text-muted"
      aria-label="Chart legend"
    >
      <span className="flex items-center gap-2">
        <svg width="28" height="6" aria-hidden="true">
          <line x1="0" y1="3" x2="28" y2="3" stroke="#3A6B7E" strokeWidth="2.5" />
        </svg>
        <span>best</span>
      </span>
      <span className="flex items-center gap-2">
        <svg width="28" height="6" aria-hidden="true">
          <line
            x1="0"
            y1="3"
            x2="28"
            y2="3"
            stroke="#6E7F8C"
            strokeWidth="1.5"
            strokeDasharray="4 4"
          />
        </svg>
        <span>generation median</span>
      </span>
    </div>
  );
}

function LiveView({
  generations,
  fitnessLabel,
  target,
  status,
}: {
  generations: GenerationEvent[];
  fitnessLabel: string;
  target: Target;
  status: Status;
}) {
  if (generations.length === 0 && status !== "running") return null;
  const series = generations
    .map((g) => ({
      generation: g.generation,
      best:
        typeof g.best_fitness === "number" && Number.isFinite(g.best_fitness)
          ? Number(g.best_fitness.toFixed(4))
          : null,
      median:
        typeof g.median_fitness === "number" && Number.isFinite(g.median_fitness)
          ? Number(g.median_fitness.toFixed(4))
          : null,
    }));
  const latest = generations[generations.length - 1];
  return (
    <SectionCard
      title="Live view"
      subtitle="Watch the engine improve, round by round."
    >
      <div className="mb-2 flex items-center justify-between">
        <h3 className="flex items-center text-sm font-medium text-ink">
          Fitness curve
          <InfoTip
            text={FITNESS_TIP_BY_TARGET[target]}
            label="About the y-axis (fitness)"
          />
        </h3>
        <span className="flex items-center text-xs text-muted">
          best vs median
          <InfoTip text={TIPS.bestVsMedian} label="About best vs median" />
        </span>
      </div>
      <div className="h-64 w-full">
        <ResponsiveContainer>
          <LineChart data={series} margin={{ top: 10, right: 20, bottom: 8, left: 12 }}>
            <CartesianGrid stroke="#ECEAE4" strokeDasharray="3 3" />
            <XAxis
              dataKey="generation"
              stroke="#6E7F8C"
              tick={{ fill: "#6E7F8C", fontSize: 11 }}
            />
            <YAxis
              stroke="#6E7F8C"
              tick={{ fill: "#6E7F8C", fontSize: 11 }}
              label={<CenteredYAxisLabel value={fitnessLabel} />}
              domain={["auto", "auto"]}
            />
            <Tooltip
              contentStyle={{
                background: "#FCFBF8",
                border: "1px solid #ECEAE4",
                color: "#23303A",
                fontSize: 12,
              }}
            />
            <Line
              type="monotone"
              dataKey="best"
              stroke="#3A6B7E"
              strokeWidth={2.5}
              dot={false}
              isAnimationActive={false}
            />
            <Line
              type="monotone"
              dataKey="median"
              stroke="#6E7F8C"
              strokeWidth={1.5}
              strokeDasharray="4 4"
              dot={false}
              isAnimationActive={false}
            />
          </LineChart>
        </ResponsiveContainer>
      </div>
      <div className="mt-1 text-center text-[11px] text-muted">generation</div>
      <ChartLegend />

      {latest && (
        <div className="mt-3 flex flex-wrap items-center gap-4 text-xs text-muted">
          <span>
            Generation {latest.generation} · best{" "}
            <span className="font-mono text-ink">
              {fmtFit(latest.best_fitness, 4)}
            </span>{" "}
            · median{" "}
            <span className="font-mono text-ink">
              {fmtFit(latest.median_fitness, 4)}
            </span>
          </span>
          <span className="flex items-center">
            gene codes
            <InfoTip
              text={TIPS.opaqueIds}
              label="About the anonymous gene codes"
            />
          </span>
        </div>
      )}
    </SectionCard>
  );
}

// Plain-reading verdict at the top of the Result panel. Green when the
// run cleared its objective's held-out bar AND beat chance (p < 0.05);
// amber otherwise. Unsupervised appends the post-hoc MSI-alignment read.
function ResultVerdict({ result }: { result: RunResult }) {
  const target = result.objective_spec.target as Target;
  const holdout = result.winning.holdout_score;
  const p = result.winning.permutation_p;
  const posthoc = result.posthoc;

  const threshold = HOLDOUT_THRESHOLD[target];
  const generalises = Number.isFinite(holdout) && holdout >= threshold;
  const significant = Number.isFinite(p) && p < P_VALUE_SIGNIFICANT;
  const success = generalises && significant;
  const pctChance = Number.isFinite(p) ? Math.round((p as number) * 100) : null;

  const callout = success ? (
    <div
      className="flex items-start gap-3 rounded-md border border-accent/40 bg-accent/5 px-4 py-3"
      role="status"
    >
      <span
        aria-hidden
        className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-accent text-card"
        style={{ fontSize: 12 }}
      >
        ✓
      </span>
      <p className="text-sm text-accent">
        Real result: it holds up on unseen patients ({fmtFit(holdout, 3)})
        and beats chance (p {fmtFit(p, 4)}).
        {target === "none" && <UnsupAlignmentTail posthoc={posthoc} />}
      </p>
    </div>
  ) : (
    <div
      className="flex items-start gap-3 rounded-md border border-highlight/40 bg-highlight/10 px-4 py-3"
      role="status"
      aria-label="warning"
    >
      <span
        aria-hidden
        className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-highlight text-card"
        style={{ fontSize: 12 }}
      >
        !
      </span>
      <p className="text-sm text-highlight">
        This run found nothing reliable: held-out {fmtFit(holdout, 3)}{" "}
        (fell apart on unseen patients) · p {fmtFit(p, 4)}{" "}
        {pctChance != null && (
          <>(a random program beats it ~{pctChance}% of the time)</>
        )}
        . Don&rsquo;t read the genes as a discovery.
      </p>
    </div>
  );

  return (
    <div className="mb-5">
      {callout}
      <VerdictBreakdown
        target={target}
        holdout={holdout}
        p={p}
        posthoc={posthoc}
        success={success}
        generalises={generalises}
        significant={significant}
      />
    </div>
  );
}

// "What does this mean?" — expandable plain-language explanation under
// the verdict headline. Closed by default; mirrors ParameterFlow's
// disclosure pattern (button + chevron + aria-expanded + useId panel).
// Content branches on objective_spec.target since MSI/TMB are
// single-question (held-out IS the answer) while unsupervised is
// two-question (split is real vs. is it MSI).
function VerdictBreakdown({
  target,
  holdout,
  p,
  posthoc,
  success,
  generalises,
  significant,
}: {
  target: Target;
  holdout: number;
  p: number;
  posthoc: RunResult["posthoc"] | undefined;
  success: boolean;
  generalises: boolean;
  significant: boolean;
}) {
  const [open, setOpen] = useState(false);
  const panelId = useId();

  return (
    <div className="mt-2 ml-2 text-xs">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        aria-controls={panelId}
        className="inline-flex items-center gap-1.5 rounded text-muted hover:text-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
      >
        <span
          aria-hidden
          style={{
            display: "inline-block",
            transition: "transform 120ms ease",
            transform: open ? "rotate(90deg)" : "rotate(0deg)",
          }}
        >
          ▸
        </span>
        <span className="font-medium">What does this mean?</span>
      </button>
      {open && (
        <div
          id={panelId}
          className="mt-2 rounded-md border border-border bg-card px-4 py-3 text-[12.5px] leading-relaxed text-ink"
        >
          {target === "none" ? (
            <UnsupBreakdownContent
              holdout={holdout}
              p={p}
              posthoc={posthoc}
              success={success}
            />
          ) : target === "tmb" ? (
            <TMBBreakdownContent
              holdout={holdout}
              p={p}
              generalises={generalises}
              significant={significant}
              success={success}
            />
          ) : target === "hpv" ? (
            <HPVBreakdownContent
              holdout={holdout}
              p={p}
              generalises={generalises}
              significant={significant}
              success={success}
            />
          ) : (
            <MSIBreakdownContent
              holdout={holdout}
              p={p}
              generalises={generalises}
              significant={significant}
              success={success}
            />
          )}
        </div>
      )}
    </div>
  );
}

function MSIBreakdownContent({
  holdout,
  p,
  generalises,
  significant,
  success,
}: {
  holdout: number;
  p: number;
  generalises: boolean;
  significant: boolean;
  success: boolean;
}) {
  const pctChance = Number.isFinite(p) ? Math.round(p * 100) : null;
  return (
    <>
      <p className="font-medium text-ink">Is this result real and useful?</p>
      <ul className="mt-2 space-y-2 pl-4">
        <li>
          <span className="font-medium">Held-out AUROC</span>{" "}
          ({fmtFit(holdout, 3)}). On patients it never trained on, how well
          the score tells MSI-H from MSS. AUROC is the chance the score
          ranks a random MSI-H patient above a random MSS patient: 0.5 =
          coin-flip, 1.0 = perfect.{" "}
          <span className="text-muted">
            {generalises
              ? "Strong."
              : "Weak — barely better than guessing."}
          </span>
        </li>
        <li>
          <span className="font-medium">Beats chance</span> (p {fmtFit(p, 4)}).
          Only about {pctChance ?? "—"}% of shuffled-label runs match this — so{" "}
          <span className="text-muted">
            {significant
              ? "unlikely a fluke."
              : "it could be luck."}
          </span>
        </li>
      </ul>
      <p className="mt-3 font-medium text-ink">
        →{" "}
        {success
          ? "Real and useful — it separates MSI-H from MSS on patients it never saw."
          : "Not reliable — don’t read the genes as a discovery."}
      </p>
      <p className="mt-2 text-[11.5px] italic text-muted">
        This is a detection result. Whether the engine found the known
        MMR / immune marker genes is a separate question — see Reveal
        &amp; evaluate below.
      </p>
    </>
  );
}

function HPVBreakdownContent({
  holdout,
  p,
  generalises,
  significant,
  success,
}: {
  holdout: number;
  p: number;
  generalises: boolean;
  significant: boolean;
  success: boolean;
}) {
  const pctChance = Number.isFinite(p) ? Math.round(p * 100) : null;
  return (
    <>
      <p className="font-medium text-ink">Is this result real and useful?</p>
      <ul className="mt-2 space-y-2 pl-4">
        <li>
          <span className="font-medium">Held-out AUROC</span>{" "}
          ({fmtFit(holdout, 3)}). On patients it never trained on, how well
          the score tells HPV+ from HPV− tumours. AUROC is the chance the
          score ranks a random HPV+ patient above a random HPV− patient:
          0.5 = coin-flip, 1.0 = perfect.{" "}
          <span className="text-muted">
            {generalises
              ? "Strong."
              : "Weak — barely better than guessing."}
          </span>
        </li>
        <li>
          <span className="font-medium">Beats chance</span> (p {fmtFit(p, 4)}).
          Only about {pctChance ?? "—"}% of shuffled-label runs match this — so{" "}
          <span className="text-muted">
            {significant
              ? "unlikely a fluke."
              : "it could be luck."}
          </span>
        </li>
      </ul>
      <p className="mt-3 font-medium text-ink">
        →{" "}
        {success
          ? "Real and useful — it separates HPV+ from HPV− tumours on patients it never saw."
          : "Not reliable — don’t read the genes as a discovery."}
      </p>
      <p className="mt-2 text-[11.5px] italic text-muted">
        This is a detection result. Whether the engine found the known
        marker genes (CDKN2A/p16, the cell-cycle program) is a separate
        question — see Reveal &amp; evaluate below for the gene overlap
        and each revealed gene's individual rank.
      </p>
    </>
  );
}

function TMBBreakdownContent({
  holdout,
  p,
  generalises,
  significant,
  success,
}: {
  holdout: number;
  p: number;
  generalises: boolean;
  significant: boolean;
  success: boolean;
}) {
  const pctChance = Number.isFinite(p) ? Math.round(p * 100) : null;
  return (
    <>
      <p className="font-medium text-ink">Is this result real and useful?</p>
      <ul className="mt-2 space-y-2 pl-4">
        <li>
          <span className="font-medium">Held-out association</span>{" "}
          ({fmtFit(holdout, 3)}). On patients it never trained on, the
          strength of the negative link between the score and mutation
          burden. Scale: 0 = no link, higher = stronger; ≥ 0.30 = a real
          link.{" "}
          <span className="text-muted">
            {generalises
              ? "Strong."
              : "Weak — barely better than guessing."}
          </span>
        </li>
        <li>
          <span className="font-medium">Beats chance</span> (p {fmtFit(p, 4)}).
          Only about {pctChance ?? "—"}% of shuffled-label runs match this — so{" "}
          <span className="text-muted">
            {significant
              ? "unlikely a fluke."
              : "it could be luck."}
          </span>
        </li>
      </ul>
      <p className="mt-3 font-medium text-ink">
        →{" "}
        {success
          ? "Real and useful."
          : "Not reliable — don’t read the genes as a discovery."}
      </p>
    </>
  );
}

function UnsupBreakdownContent({
  holdout,
  p,
  posthoc,
  success,
}: {
  holdout: number;
  p: number;
  posthoc: RunResult["posthoc"] | undefined;
  success: boolean;
}) {
  const pctChance = Number.isFinite(p) ? Math.round(p * 100) : null;
  const hpv = posthoc?.hpv_auroc;
  const msi = posthoc?.msi_auroc;
  const tmb = posthoc?.tmb_abs_spearman;
  const hpvKnown = hpv != null && Number.isFinite(hpv);
  const msiKnown = msi != null && Number.isFinite(msi);
  // HNSC unsup carries hpv; coadread unsup carries msi+tmb. Each cohort
  // shows the alignment question that's actually answerable.
  const showHPV = hpvKnown;
  const hpvAligned = hpvKnown && (hpv as number) >= POSTHOC_HPV_ALIGN;
  const msiAligned = msiKnown && (msi as number) >= POSTHOC_MSI_ALIGN;

  return (
    <>
      <p className="text-ink">
        This is answering <span className="font-medium">two separate questions.</span>
      </p>

      <p className="mt-3 font-medium text-ink">1) Is the split real?</p>
      <ul className="mt-1 space-y-2 pl-4">
        <li>
          <span className="font-medium">Holds up on unseen patients</span>{" "}
          ({fmtFit(holdout, 3)}). The engine found the split on one set of
          patients, then it still cleanly separated a different set it never
          saw. Scale: −1 = no real split, ~1 = two very clean,
          well-separated groups.
        </li>
        <li>
          <span className="font-medium">Beats chance</span> (p {fmtFit(p, 4)}).
          Only about {pctChance ?? "—"}% of random programs produce a split
          this clean — so it&rsquo;s very unlikely to be a fluke.
        </li>
      </ul>
      <p className="mt-2 text-muted">
        → Together:{" "}
        {success
          ? "yes, it found a real, reproducible way to split patients into two groups."
          : "no — the split didn’t hold up on unseen patients or could be luck. Don’t read the genes as a discovery."}
      </p>

      {showHPV ? (
        <>
          <p className="mt-4 font-medium text-ink">
            2) Is that split the HPV+ subtype?
          </p>
          <ul className="mt-1 space-y-2 pl-4">
            <li>
              <span className="font-medium">Match to HPV</span>:{" "}
              {fmtFit(hpv, 3)}. Scale: 0.5 = no relationship (coin-flip),
              1.0 = a perfect match.{" "}
              <span className="text-muted">
                {hpvAligned
                  ? "So this split largely IS the HPV+ subtype — the engine rediscovered the viral signature blind."
                  : "So this split is NOT the HPV+ subtype — it’s a real grouping about something else (see the revealed genes)."}
              </span>
            </li>
          </ul>
        </>
      ) : (
        <>
          <p className="mt-4 font-medium text-ink">2) Is that split the MSI subtype?</p>
          <ul className="mt-1 space-y-2 pl-4">
            <li>
              <span className="font-medium">Match to MSI</span>:{" "}
              {msiKnown ? fmtFit(msi, 3) : "—"}. Scale: 0.5 = no relationship
              (coin-flip), 1.0 = a perfect match.{" "}
              {msiKnown ? (
                <span className="text-muted">
                  {msiAligned
                    ? "So this split largely IS MSI — the engine rediscovered the subtype blind."
                    : "So this split is NOT the MSI subtype — it’s a real grouping about something else (see the revealed genes)."}
                </span>
              ) : (
                <span className="text-muted">
                  (Too few held-out labels to check alignment.)
                </span>
              )}
            </li>
            <li className="text-muted">
              (Match to mutation burden, TMB:{" "}
              {tmb != null && Number.isFinite(tmb) ? fmtFit(tmb, 3) : "—"} — 0 =
              unrelated, 1 = move together perfectly.)
            </li>
          </ul>
        </>
      )}

      <p className="mt-3 italic text-muted">
        Key idea: a split can be completely real and still be about a
        different thing than you hoped — like sorting a crowd cleanly by
        hair colour when you wanted left- vs right-handed.
      </p>
    </>
  );
}

function UnsupAlignmentTail({
  posthoc,
}: {
  posthoc: RunResult["posthoc"] | undefined;
}) {
  if (!posthoc) return null;
  // Pick whichever supervised label the cohort actually carries.
  // HNSC unsup runs report hpv_auroc; coadread unsup runs report msi_auroc.
  const hpv = posthoc.hpv_auroc;
  if (hpv != null && Number.isFinite(hpv)) {
    if (hpv >= POSTHOC_HPV_ALIGN) {
      return (
        <>
          {" "}and the blind split matches HPV (AUROC {fmtFit(hpv, 3)}) — it
          rediscovered the viral subtype.
        </>
      );
    }
    return (
      <>
        {" "}but the split doesn&rsquo;t line up with HPV (AUROC{" "}
        {fmtFit(hpv, 3)}).
      </>
    );
  }
  const auroc = posthoc.msi_auroc;
  if (auroc == null || !Number.isFinite(auroc)) {
    return <> (too few held-out labels to check alignment)</>;
  }
  if (auroc >= POSTHOC_MSI_ALIGN) {
    return (
      <>
        {" "}and the blind split matches MSI (AUROC {fmtFit(auroc, 3)}) — it
        rediscovered the subtype.
      </>
    );
  }
  return (
    <>
      {" "}but the split doesn&rsquo;t line up with MSI (AUROC{" "}
      {fmtFit(auroc, 3)}).
    </>
  );
}

function ResultPanel({
  result,
  dataset,
  target,
  runId,
  coherence,
  diversity,
  params,
  dslRates,
}: {
  result: RunResult;
  dataset: DatasetId;
  target: Target;
  runId: string | null;
  coherence: boolean;
  diversity: boolean;
  params: RunParams;
  dslRates: DslRates;
}) {
  const w = result.winning;
  const b = result.baseline;
  const t = result.objective_spec.target as Target;
  // The merged Groups table is gated to coherence-on supervised runs
  // — same gate <ModuleRankingPanel> uses. Only point at it when it
  // will actually render.
  const pointToGroups = target !== "none" && coherence;

  return (
    <SectionCard
      title="Result"
      subtitle="What the engine settled on, judged on patients it never trained on."
    >
      <ResultVerdict result={result} />
      <div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
        <Metric
          label={HELD_OUT_LABEL_BY_TARGET[t]}
          tip={HELD_OUT_TIP_BY_TARGET[t]}
          value={fmtFit(w.holdout_score, 3)}
        />
        <Metric label="Permutation p" tip={PERMUTATION_P_TIP_BY_TARGET[t]} value={fmtFit(w.permutation_p, 4)} />
        {b ? (
          <Metric label="Baseline" tip={TIPS.baseline} value={fmtFit(b.holdout_score, 3)} />
        ) : (
          <Metric
            label="Nodes"
            tip={TIPS.nodes}
            value={w.n_nodes != null ? String(w.n_nodes) : "—"}
          />
        )}
        <Metric label="Genes" tip={TIPS.genes} value={String(w.gene_ids.length)} />
      </div>
      {pointToGroups && (
        <p className="mt-3 text-[11px] italic text-muted">
          The winning program is badged in{" "}
          <span className="font-semibold not-italic text-ink">
            Groups the engine explored
          </span>{" "}
          below — that panel is the engine&rsquo;s actual picks
          ranked by how the search judged them, and each row shows
          its genes and where each gene ranks on its own.
        </p>
      )}
      <div className="mt-5">
        <div className="flex items-center justify-between gap-3">
          <div className="flex items-center text-xs tracking-wide text-muted">
            Winning program
            <InfoTip
              text={TIPS.winningProgram}
              label="About the winning program"
            />
          </div>
          <CopyButton
            text={w.program_repr}
            label="Copy program"
            ariaLabel="Copy the winning program text"
          />
        </div>
        <div className="mt-1 font-mono text-sm text-ink">{w.program_repr}</div>
      </div>
      {w.feature_sets && w.feature_sets.length > 0 && (
        <div className="mt-4 text-xs text-muted">
          feature sets:{" "}
          <code className="text-ink">
            {w.feature_sets.map((s) => `[${s.join(", ")}]`).join("  ")}
          </code>
        </div>
      )}

      {result.posthoc && result.objective_spec.target === "none" && (
        <PosthocAlignment posthoc={result.posthoc} />
      )}

      {dataset === "hnsc" && target === "hpv" && runId && (
        <ExternalValidation runId={runId} />
      )}
      {dataset === "hnsc" && target === "hpv" && (
        <StabilityPanel
          dataset={dataset}
          target={target}
          params={params}
          coherence={coherence}
          diversity={diversity}
          dslRates={dslRates}
        />
      )}
    </SectionCard>
  );
}


// Post-hoc alignment block for an unsupervised run: show how the
// discovered cluster split lines up with the MSI label and TMB,
// computed AFTER the GP finished by the api worker (engine remains
// blind during the search).
function PosthocAlignment({ posthoc }: { posthoc: NonNullable<RunResult["posthoc"]> }) {
  // HNSC unsup runs carry hpv_auroc; coadread unsup runs carry msi /
  // tmb. Pick the alignment that has actual data — neither cohort has
  // both, so showing the empty one is just noise.
  const showHPV = posthoc.hpv_auroc != null && Number.isFinite(posthoc.hpv_auroc);
  const tipKey: DatasetId = showHPV ? "hnsc" : "coadread";

  return (
    <div className="mt-5 rounded-md border border-accent/30 bg-accent/5 px-4 py-3">
      <div className="flex items-center text-[10px] tracking-wide text-accent">
        Post-hoc alignment
        <InfoTip
          text={POSTHOC_TIP_BY_DATASET[tipKey]}
          label="About the post-hoc alignment"
        />
      </div>
      {showHPV ? (
        <p className="mt-2 text-sm text-ink">
          The discovered 2-cluster split aligns with{" "}
          <span className="font-semibold">HPV</span> at AUROC{" "}
          <span className="font-mono">{fmtFit(posthoc.hpv_auroc, 3)}</span>.
        </p>
      ) : (
        <p className="mt-2 text-sm text-ink">
          The discovered 2-cluster split aligns with{" "}
          <span className="font-semibold">MSI</span>{" "}
          {posthoc.msi_auroc != null ? (
            <>
              at AUROC{" "}
              <span className="font-mono">{fmtFit(posthoc.msi_auroc, 3)}</span>
            </>
          ) : (
            <span className="italic text-muted">— (too few held-out labels)</span>
          )}
          ; the score&rsquo;s |spearman| with{" "}
          <span className="font-semibold">TMB</span> is{" "}
          {posthoc.tmb_abs_spearman != null ? (
            <span className="font-mono">{fmtFit(posthoc.tmb_abs_spearman, 3)}</span>
          ) : (
            <span className="italic text-muted">— (insufficient TMB)</span>
          )}
          .
        </p>
      )}
      <p className="mt-1 text-[11px] italic text-muted">
        {posthoc.n_holdout} held-out patients · labels were never visible to
        the engine during search.
      </p>
    </div>
  );
}


// Independent-cohort validation panel — GSE65858 transfer test for
// HNSC/HPV runs. Fetches /runs/{id}/transfer once the run is done;
// renders a green "holds on strangers" verdict callout (or an amber
// "found nothing reliable" one), three metric cards, an amber
// cross-platform pill, a three-step "how this works" strip, an airgap
// footnote, and one honest note. Only surfaces symbols that were
// already revealed on the blind side (the winner's own genes).
function ExternalValidation({ runId }: { runId: string }) {
  const [data, setData] = useState<TransferResult | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [loading, setLoading] = useState<boolean>(true);
  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);
    setData(null);
    getRunTransfer(runId)
      .then((d) => {
        if (!cancelled) setData(d);
      })
      .catch((e) => {
        if (!cancelled) setError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [runId]);

  return (
    <div className="mt-6 border-t border-border pt-5">
      <div className="flex items-center text-xs tracking-wide text-muted">
        Independent-cohort validation
        <InfoTip
          text={
            "The winning pattern was found blind on TCGA. Here it&rsquo;s " +
            "applied, unchanged, to a completely separate group of head " +
            "& neck patients — a different country, hospital, and " +
            "measuring machine. If the score still separates HPV+ from " +
            "HPV− there, the pattern generalises."
          }
          label="About the independent-cohort validation"
        />
      </div>
      <p className="mt-1 text-[11.5px] italic text-muted">
        The winning pattern was found blind on TCGA. Here it&rsquo;s
        applied, unchanged, to a completely separate group of head &amp;
        neck patients — a different country, hospital, and measuring
        machine.
      </p>

      {loading && (
        <p className="mt-3 text-xs text-muted">running the transfer test…</p>
      )}
      {!loading && (error || !data) && (
        <p className="mt-3 rounded-md border border-border bg-bg px-3 py-2 text-[11.5px] italic text-muted">
          Couldn&rsquo;t validate externally: {error ?? "unknown error"}.
          The main result stands — this extra check is available once
          the GSE65858 cohort is built (see
          <code className="ml-1 font-mono text-ink">
            data_pipeline/build_gse65858
          </code>
          ).
        </p>
      )}

      {!loading && data && data.n_found === 0 && (
        <p className="mt-3 rounded-md border border-border bg-bg px-3 py-2 text-[11.5px] italic text-muted">
          Couldn&rsquo;t validate externally — none of the winner&rsquo;s
          genes are measured on GSE65858&rsquo;s platform (Illumina
          HumanHT-12 v4 microarray). The main result stands; this extra
          check is only meaningful when at least some of the winner&rsquo;s
          genes carry over.
        </p>
      )}

      {!loading && data && data.n_found > 0 && (
        <ExternalValidationBody data={data} />
      )}
    </div>
  );
}

function ExternalValidationBody({ data }: { data: TransferResult }) {
  const isReal =
    data.auroc != null
    && data.p != null
    && data.auroc >= 0.75
    && data.p < 0.05;
  const pPct =
    data.p != null && Number.isFinite(data.p)
      ? Math.max(0.01, Math.min(99.99, data.p * 100))
      : null;

  return (
    <>
      <div
        className={
          "mt-3 flex items-start gap-3 rounded-md border px-4 py-3 " +
          (isReal
            ? "border-accent/40 bg-accent/5"
            : "border-highlight/40 bg-highlight/5")
        }
        role="status"
      >
        <span
          aria-hidden
          className={
            "mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full text-card "
            + (isReal ? "bg-accent" : "bg-highlight")
          }
          style={{ fontSize: 12 }}
        >
          {isReal ? "✓" : "?"}
        </span>
        <p className={"text-sm " + (isReal ? "text-accent" : "text-highlight")}>
          {isReal ? (
            <>
              <span className="font-semibold">It holds on strangers.</span>{" "}
              The pattern still separates HPV+ from HPV− in an
              independent cohort — AUROC{" "}
              <span className="font-mono">{fmtFit(data.auroc, 3)}</span>,
              and only {pPct != null ? pPct.toFixed(1) : "—"}% of random
              labellings do this well (p{" "}
              <span className="font-mono">{fmtFit(data.p, 3)}</span>).
            </>
          ) : (
            <>
              <span className="font-semibold">Not confirmed on strangers.</span>{" "}
              On the independent cohort the pattern reached AUROC{" "}
              <span className="font-mono">{fmtFit(data.auroc, 3)}</span>
              {data.p != null && (
                <>
                  {" "}with p{" "}
                  <span className="font-mono">{fmtFit(data.p, 3)}</span>
                </>
              )}{" "}
              — below the &ldquo;holds up&rdquo; bar. The main result
              stands; treat the transfer step as inconclusive here.
            </>
          )}
        </p>
      </div>

      <div className="mt-3 grid grid-cols-2 gap-3 sm:grid-cols-3">
        <Metric
          label="Independent AUROC"
          tip={
            "How well the winning pattern separates HPV+ from HPV− on a "
            + "COMPLETELY DIFFERENT cohort (GSE65858 — different country, "
            + "hospital, and measuring machine). 0.5 = coin-flip, 1.0 = "
            + "perfect. Rules out &ldquo;you just overfit TCGA.&rdquo;"
          }
          value={fmtFit(data.auroc, 3)}
          sub={`GSE65858 · ${data.n} patients`}
        />
        <Metric
          label="Permutation p"
          tip={
            "How often random labels score this well on the same cohort. "
            + "Small = unlikely to be a fluke."
          }
          value={fmtFit(data.p, 3)}
          sub="beats random labels"
        />
        <Metric
          label="Genes measurable"
          tip={
            "Array cohorts don't carry every gene — some of the winner's "
            + "genes may be absent. The transfer score uses the ones that "
            + "are present."
          }
          value={`${data.n_found} / ${data.n_found + data.n_missing}`}
          sub={`of the winner's genes`}
        />
      </div>

      <div className="mt-3 rounded-md border border-[#BC6B2E]/40 bg-[#FBF1E6] px-3 py-2 text-[11.5px] text-[#8A4E20]">
        <span className="font-semibold">Validated across two platforms.</span>{" "}
        TCGA RNA-seq → GSE65858 microarray. Agreement across different
        machines is a stronger result, not a weaker one.
      </div>

      <div className="mt-4">
        <div className="text-[11px] font-semibold tracking-wide text-[#3A6B7E]">
          How this validation works
        </div>
        <div className="mt-2 grid grid-cols-1 gap-2 sm:grid-cols-3">
          {[
            {
              n: "1",
              head: "reveal",
              body: "Unmask only the winner's handful of genes — the one thing that crosses over.",
            },
            {
              n: "2",
              head: "match & level",
              body: "Find those genes in GSE65858 and put them on a common scale, so RNA-seq vs microarray doesn't matter.",
            },
            {
              n: "3",
              head: "score",
              body: "Average them per patient and check how well that separates HPV+ from HPV− — against the virus-typed labels.",
            },
          ].map((step) => (
            <div
              key={step.n}
              className="rounded-md border border-[#3A6B7E]/30 bg-[#EAF0F2] px-3 py-2 text-[11.5px] text-[#2C5563]"
            >
              <div className="font-semibold">
                {step.n} · {step.head}
              </div>
              <div className="mt-0.5 leading-snug">{step.body}</div>
            </div>
          ))}
        </div>
      </div>

      <div className="mt-3 flex items-start gap-2 rounded-md border border-[#3A6B7E]/30 bg-[#EAF0F2] px-3 py-2 text-[11.5px] text-[#2C5563]">
        <span aria-hidden style={{ fontSize: 14 }}>🔒</span>
        <p>
          <span className="font-semibold">The airgap holds.</span> The
          engine never sees this cohort. Only the winning gene names
          cross over, once, at the end — so a result here is genuine
          rediscovery, not a lookup.
        </p>
      </div>

      <p className="mt-3 text-[11px] italic text-muted">
        One honest note. This checks the gene <em>signature</em> the
        winner chose. For a simple winner that is the whole program;
        for a multi-part one it&rsquo;s the gene set rather than the
        exact arithmetic — which, across two platforms, is the more
        honest thing to measure anyway.
      </p>
    </>
  );
}


// ---------------------------------------------------------------------------
// Stability across seeds. Reruns the HPV pipeline across a list of
// seeds and shows (a) how tightly the result holds and (b) which
// genes recur across independent searches. Frontend-only — reuses
// postRun / SSE / getRunResult / getRunTransfer / getFullRankDiagnostic;
// no new endpoint. Sequential (backend runs one at a time). Gated to
// HNSC/HPV by the caller.
// ---------------------------------------------------------------------------

const STABILITY_DEFAULT_SEEDS = [1, 3, 7, 11, 13, 17, 23, 29];

type SeedRow = {
  seed: number;
  holdout: number | null;
  permutation_p: number | null;
  symbols: string[];
  transfer: TransferResult | null;
  error: string | null;
};

type StabilityStatus = "idle" | "running" | "done" | "error";

function parseSeedList(input: string): number[] {
  return Array.from(
    new Set(
      input
        .split(/[\s,]+/)
        .map((s) => s.trim())
        .filter((s) => s.length > 0)
        .map((s) => Number(s))
        .filter((n) => Number.isFinite(n) && Number.isInteger(n) && n >= 0 && n < 1_000_000_000),
    ),
  );
}

/** Fire a run's SSE stream and resolve on ``done``. Does NOT touch the
 *  main single-run state (setResult / setGenerations / setStatus). */
function awaitRunDone(runId: string): Promise<RunResult> {
  return new Promise<RunResult>((resolve, reject) => {
    const es = new EventSource(`${API_URL}/runs/${runId}/stream`);
    let settled = false;
    const finish = (done: () => void) => {
      if (settled) return;
      settled = true;
      try { es.close(); } catch { /* noop */ }
      done();
    };
    es.addEventListener("done", (ev) => {
      finish(() => {
        try {
          const data = JSON.parse((ev as MessageEvent).data) as RunResult;
          resolve(data);
        } catch (e) {
          reject(e instanceof Error ? e : new Error(String(e)));
        }
      });
    });
    es.addEventListener("error", (ev) => {
      finish(() => {
        try {
          const data = JSON.parse((ev as MessageEvent).data) as { detail?: string };
          reject(new Error(data.detail ?? "run error"));
        } catch {
          // Some SSE errors don't carry a JSON body — fall back to a
          // generic message.
          reject(new Error("stream error"));
        }
      });
    });
    // Note: EventSource emits "error" for network hiccups too. We treat
    // it as a hard reject — the sweep records the error and continues
    // with the next seed.
  });
}

function StabilityPanel({
  dataset,
  target,
  params,
  coherence,
  diversity,
  dslRates,
}: {
  dataset: DatasetId;
  target: Target;
  params: RunParams;
  coherence: boolean;
  diversity: boolean;
  dslRates: DslRates;
}) {
  const [seedInput, setSeedInput] = useState<string>(
    STABILITY_DEFAULT_SEEDS.join(", "),
  );
  const [status, setStatus] = useState<StabilityStatus>("idle");
  const [progress, setProgress] = useState<{ k: number; total: number } | null>(null);
  const [rows, setRows] = useState<SeedRow[]>([]);
  const [errorMsg, setErrorMsg] = useState<string | null>(null);
  const cancelRef = useRef<boolean>(false);
  const [tagBySymbol, setTagBySymbol] = useState<Record<string, string>>({});

  const running = status === "running";

  // A global signal the outer Run bar can honour to disable itself
  // while a sweep is in flight. We set it on window so it doesn't
  // require plumbing changes; the RunBar reads it via the same-file
  // check below.
  useEffect(() => {
    if (typeof window === "undefined") return;
    (window as unknown as { __stabilitySweepActive?: boolean }).__stabilitySweepActive = running;
  }, [running]);

  async function runSweep() {
    const seeds = parseSeedList(seedInput);
    if (seeds.length === 0) {
      setErrorMsg("Enter at least one seed.");
      return;
    }
    setStatus("running");
    setErrorMsg(null);
    setRows([]);
    setProgress({ k: 0, total: seeds.length });
    cancelRef.current = false;

    // Only send DSL rates that differ from defaults — same discipline
    // launchRun() uses so unaltered runs stay byte-for-byte identical
    // to the current defaults.
    const ratesDiff: Record<string, number> = {};
    (Object.keys(DSL_DEFAULT_RATES) as (keyof DslRates)[]).forEach((k) => {
      if (dslRates[k] !== DSL_DEFAULT_RATES[k]) ratesDiff[k] = dslRates[k];
    });

    const collected: SeedRow[] = [];
    for (let i = 0; i < seeds.length; i++) {
      if (cancelRef.current) break;
      const seed = seeds[i];
      setProgress({ k: i + 1, total: seeds.length });
      let row: SeedRow = {
        seed,
        holdout: null,
        permutation_p: null,
        symbols: [],
        transfer: null,
        error: null,
      };
      try {
        const body: Parameters<typeof postRun>[0] = {
          objective_spec: OBJECTIVE_PRESETS[target],
          params: { ...params, seed },
          engine: "v2",
          dataset,
          coherence,
          diversity,
        };
        if (Object.keys(ratesDiff).length > 0) body.rates_override = ratesDiff;
        const { run_id } = await postRun(body);
        const result = await awaitRunDone(run_id);
        row.holdout = result.winning.holdout_score;
        row.permutation_p = result.winning.permutation_p;
        // Reveal only this seed's winner's genes (bounded — same
        // discipline as /transfer).
        const winnerIds = result.winning.gene_ids ?? [];
        if (winnerIds.length > 0) {
          try {
            const r = await postReveal(winnerIds);
            row.symbols = r.symbols
              .filter((s: string) => typeof s === "string" && s.length > 0);
          } catch {
            // Non-fatal — keep the row without symbols.
          }
        }
        // Transfer test. Nulls on failure (e.g. GSE65858 not built);
        // never crash the sweep.
        try {
          row.transfer = await getRunTransfer(run_id);
        } catch {
          row.transfer = null;
        }
      } catch (e) {
        row.error = e instanceof Error ? e.message : String(e);
      }
      collected.push(row);
      setRows([...collected]);
    }

    // Tag genes by reference set (via reference_marks from
    // /diagnostic/full-rank — bounded, already-revealed known set).
    try {
      const diag = await getFullRankDiagnostic(dataset, target);
      const tags: Record<string, string> = {};
      for (const m of diag.reference_marks ?? []) {
        // p16 wins over cell_cycle if a symbol is in both.
        if (!tags[m.symbol] || m.set_name === "p16") {
          tags[m.symbol] = m.set_name;
        }
      }
      setTagBySymbol(tags);
    } catch {
      setTagBySymbol({});
    }

    setProgress(null);
    setStatus(cancelRef.current ? "idle" : "done");
    cancelRef.current = false;
  }

  function stopSweep() {
    cancelRef.current = true;
  }

  return (
    <div className="mt-6 border-t border-border pt-5">
      <div className="flex items-center text-xs tracking-wide text-muted">
        Stability across seeds
        <InfoTip
          text={
            "Reruns the HPV search with different random starting seeds. " +
            "Spread — do independent runs land near the same held-out and " +
            "independent-cohort AUROCs? — tells you the result isn't one " +
            "lucky seed. Recurrence — do the same genes keep showing up " +
            "across runs? — tells you the biology is stable. Each seed is " +
            "a full run, so the sweep takes a while."
          }
          label="About the stability sweep"
        />
      </div>
      <p className="mt-0.5 text-[11.5px] italic text-muted">
        Rerun the HPV search from different random starting points and see
        whether the answer holds — the same held-out AUROC bracket, the
        same genes coming back.
      </p>

      <StabilityControls
        seedInput={seedInput}
        onSeedInput={setSeedInput}
        running={running}
        onStart={runSweep}
        onStop={stopSweep}
        progress={progress}
        error={errorMsg}
      />

      {rows.length > 0 && (
        <StabilityResults
          rows={rows}
          tagBySymbol={tagBySymbol}
          done={status === "done"}
        />
      )}
    </div>
  );
}

function StabilityControls({
  seedInput,
  onSeedInput,
  running,
  onStart,
  onStop,
  progress,
  error,
}: {
  seedInput: string;
  onSeedInput: (s: string) => void;
  running: boolean;
  onStart: () => void;
  onStop: () => void;
  progress: { k: number; total: number } | null;
  error: string | null;
}) {
  return (
    <div className="mt-3 flex flex-col gap-2 rounded-md border border-border bg-bg px-3 py-2 sm:flex-row sm:items-center sm:gap-3">
      <label className="flex items-center gap-2 text-xs text-ink">
        <span className="text-muted">seeds</span>
        <input
          type="text"
          value={seedInput}
          onChange={(e) => onSeedInput(e.target.value)}
          disabled={running}
          placeholder="1, 3, 7, 11, 13, 17, 23, 29"
          className="w-64 rounded-md border border-border bg-card px-2 py-1 font-mono text-[11.5px] text-ink focus:border-accent focus:outline-none disabled:opacity-50"
          aria-label="Seed list (comma- or space-separated)"
        />
      </label>
      <div className="flex items-center gap-2">
        {!running ? (
          <button
            type="button"
            onClick={onStart}
            className="rounded-md bg-accent px-3 py-1.5 text-xs font-medium text-card hover:bg-ink"
          >
            Run stability check
          </button>
        ) : (
          <button
            type="button"
            onClick={onStop}
            className="rounded-md border border-highlight/40 bg-highlight/10 px-3 py-1.5 text-xs font-medium text-highlight hover:bg-highlight/20"
          >
            Stop after this seed
          </button>
        )}
        {progress && (
          <span className="text-[11px] text-muted">
            Running seed {progress.k} of {progress.total}…
          </span>
        )}
      </div>
      {error && (
        <p className="rounded-md border border-highlight/40 bg-highlight/10 px-2 py-1 text-[11px] text-highlight sm:ml-auto">
          {error}
        </p>
      )}
    </div>
  );
}

function StabilityResults({
  rows,
  tagBySymbol,
  done,
}: {
  rows: SeedRow[];
  tagBySymbol: Record<string, string>;
  done: boolean;
}) {
  // ----- Section 1: dot strips -----
  const holdouts = rows.map((r) => r.holdout).filter((v): v is number => v != null);
  const xfers = rows
    .map((r) => r.transfer?.auroc ?? null)
    .filter((v): v is number => v != null);
  const heldRange: [number, number] | null =
    holdouts.length > 0 ? [Math.min(...holdouts), Math.max(...holdouts)] : null;
  const xferRange: [number, number] | null =
    xfers.length > 0 ? [Math.min(...xfers), Math.max(...xfers)] : null;

  // ----- Section 2: recurrence -----
  const symbolCounts: Record<string, number> = {};
  for (const r of rows) {
    // dedup within a winner
    const uniq = Array.from(new Set(r.symbols));
    for (const s of uniq) symbolCounts[s] = (symbolCounts[s] ?? 0) + 1;
  }
  const N = rows.length;
  type RecEntry = { symbol: string; count: number; tag: string };
  const rec: RecEntry[] = Object.entries(symbolCounts)
    .map(([symbol, count]) => ({
      symbol,
      count,
      tag: tagBySymbol[symbol] ?? "alternate",
    }))
    .sort((a, b) => b.count - a.count || a.symbol.localeCompare(b.symbol));
  const recurring = rec.filter((e) => e.count >= 2);
  const passengers = rec.filter((e) => e.count === 1);

  return (
    <div className="mt-4 space-y-5">
      {done && (
        <StabilitySummaryCallout
          nSeeds={N}
          heldRange={heldRange}
          xferRange={xferRange}
          allHeldSig={
            rows.length > 0
            && rows.every(
              (r) => r.permutation_p != null && r.permutation_p < 0.05,
            )
          }
          allIndepSig={
            rows.length > 0
            && rows.every(
              (r) => r.transfer?.p != null && r.transfer.p < 0.05,
            )
          }
          topGeneEntry={rec.find((e) => e.count >= 2) ?? null}
          nOnce={passengers.length}
        />
      )}
      <StabilityDotStrips
        heldRange={heldRange}
        xferRange={xferRange}
        rows={rows}
      />
      <StabilityRecurrence
        recurring={recurring}
        passengers={passengers}
        totalSeeds={N}
      />
      <StabilityTable rows={rows} />
    </div>
  );
}

// Deterministic, LLM-free plain-language summary composed from the
// completed sweep's aggregates. Rendered above the dot strips when
// the sweep finishes. Border/tone: green if perfStable && geneStable;
// teal if perfStable && !geneStable; amber otherwise.
function StabilitySummaryCallout({
  nSeeds,
  heldRange,
  xferRange,
  allHeldSig,
  allIndepSig,
  topGeneEntry,
  nOnce,
}: {
  nSeeds: number;
  heldRange: [number, number] | null;
  xferRange: [number, number] | null;
  allHeldSig: boolean;
  allIndepSig: boolean;
  topGeneEntry: { symbol: string; count: number; tag: string } | null;
  nOnce: number;
}) {
  if (nSeeds === 0) return null;
  const heldMin = heldRange ? heldRange[0] : null;
  const heldMax = heldRange ? heldRange[1] : null;
  const indepMin = xferRange ? xferRange[0] : null;
  const indepMax = xferRange ? xferRange[1] : null;
  const perfStable = heldMin != null && heldMin >= 0.75 && allHeldSig;
  const topGene = topGeneEntry?.symbol ?? null;
  const topCount = topGeneEntry?.count ?? 0;
  const geneStable = topGene != null && topCount >= Math.ceil(nSeeds / 2);

  // Tone.
  let border = "#B3402F";
  let bg = "#FBE6E6";
  let fg = "#8A3225";
  if (perfStable && geneStable) {
    border = "#2F6E4C";
    bg = "#E3F2EA";
    fg = "#1F4A34";
  } else if (perfStable && !geneStable) {
    border = "#3A6B7E";
    bg = "#EAF0F2";
    fg = "#2C5563";
  }

  // Line 1 — performance.
  const heldPart =
    heldMin != null && heldMax != null
      ? `held-out AUROC ${fmtFit(heldMin, 3)}–${fmtFit(heldMax, 3)}`
      : "held-out AUROC not available";
  const indepPart =
    indepMin != null && indepMax != null
      ? ` on the independent cohort (${fmtFit(indepMin, 3)}–${fmtFit(indepMax, 3)})`
      : "";
  const chanceTail = allIndepSig
    ? ", and every run beat chance."
    : ", and most runs beat chance on the independent cohort.";
  const line1 = `Across ${nSeeds} independent searches, the engine detected HPV every time — ${heldPart}${indepPart}${chanceTail}`;

  // Line 2 — gene recurrence.
  let line2: string;
  if (geneStable && topGene) {
    line2 = `The same genes kept coming back — ${topGene} in ${topCount}/${nSeeds} runs — which is strong evidence the signature itself is real, not a fluke of one search.`;
  } else if (topGene) {
    line2 = `But the specific genes differed almost every run — the most repeated was ${topGene} (${topCount}/${nSeeds}), and ${nOnce} genes appeared only once. That's the mark of a strong but redundant signal: many different gene sets separate HPV equally well.`;
  } else {
    // No gene appeared more than once at all.
    line2 = `But the specific genes differed every run — no single gene showed up in more than one seed's winner, and ${nOnce} genes appeared only once. That's the mark of a strong but redundant signal: many different gene sets separate HPV equally well.`;
  }

  const line3 = geneStable
    ? null
    : `So trust the detection, but don't read any single run's gene list as THE gene list — for a specific, stable gene program you'd need a target where the answer is concentrated in a few genes.`;

  return (
    <div
      role="status"
      className="rounded-md border px-4 py-3 text-[12px] leading-relaxed"
      style={{ borderColor: border, background: bg, color: fg }}
    >
      <p>{line1}</p>
      <p className="mt-1.5">{line2}</p>
      {line3 && <p className="mt-1.5">{line3}</p>}
    </div>
  );
}

function StabilityDotStrips({
  heldRange,
  xferRange,
  rows,
}: {
  heldRange: [number, number] | null;
  xferRange: [number, number] | null;
  rows: SeedRow[];
}) {
  return (
    <div>
      <div className="text-[11.5px] font-semibold text-ink">
        Does the result hold across seeds?
      </div>
      <DotStrip
        label="Held-out AUROC"
        sublabel={heldRange ? `${fmtFit(heldRange[0], 3)}–${fmtFit(heldRange[1], 3)}` : "—"}
        values={rows.map((r) => r.holdout)}
        color="#3A6B7E"
        band="#D9E4EA"
      />
      <DotStrip
        label="Independent AUROC (GSE65858)"
        sublabel={xferRange ? `${fmtFit(xferRange[0], 3)}–${fmtFit(xferRange[1], 3)}` : "—"}
        values={rows.map((r) => r.transfer?.auroc ?? null)}
        color="#BC6B2E"
        band="#F5E5D4"
      />
      <p className="mt-2 text-[11px] italic text-muted">
        Tight and high across seeds = the result isn&rsquo;t a lucky
        random start. The independent cohort usually costs a little —
        that&rsquo;s expected across countries, hospitals, and platforms.
      </p>
    </div>
  );
}

function DotStrip({
  label,
  sublabel,
  values,
  color,
  band,
}: {
  label: string;
  sublabel: string;
  values: (number | null)[];
  color: string;
  band: string;
}) {
  const W = 640;
  const H = 44;
  const padL = 40;
  const padR = 40;
  const innerW = W - padL - padR;
  const AXIS_MIN = 0.5;
  const AXIS_MAX = 1.0;
  const y = 26;
  function xOf(v: number): number {
    const t = Math.max(0, Math.min(1, (v - AXIS_MIN) / (AXIS_MAX - AXIS_MIN)));
    return padL + t * innerW;
  }
  const finite = values.filter((v): v is number => v != null && Number.isFinite(v));
  const vmin = finite.length > 0 ? Math.min(...finite) : null;
  const vmax = finite.length > 0 ? Math.max(...finite) : null;
  return (
    <div className="mt-2 rounded-md border border-border bg-card px-3 py-2">
      <div className="flex items-baseline justify-between text-[11px] text-muted">
        <span>
          <span className="font-semibold text-ink">{label}</span>{" "}
          <span className="ml-1 font-mono text-ink">{sublabel}</span>
        </span>
        <span>
          {finite.length} of {values.length} seeds
        </span>
      </div>
      <svg
        viewBox={`0 0 ${W} ${H}`}
        width="100%"
        height={H}
        role="img"
        aria-label={`${label} dot strip`}
        style={{ display: "block" }}
      >
        {/* min–max band */}
        {vmin != null && vmax != null && (
          <rect
            x={xOf(vmin)}
            y={y - 6}
            width={Math.max(2, xOf(vmax) - xOf(vmin))}
            height={12}
            fill={band}
          />
        )}
        {/* baseline */}
        <line
          x1={padL} y1={y} x2={padL + innerW} y2={y}
          stroke="#ECEAE4" strokeWidth={1}
        />
        {/* tick labels: 0.5, 0.75, 1.0 */}
        {[0.5, 0.75, 1.0].map((t) => (
          <g key={t}>
            <line
              x1={xOf(t)} y1={y - 3} x2={xOf(t)} y2={y + 3}
              stroke="#C9CCC4" strokeWidth={1}
            />
            <text
              x={xOf(t)}
              y={H - 4}
              fontSize={9}
              fill="#9AA0A6"
              textAnchor="middle"
            >
              {t === 0.5 ? "0.5 coin-flip" : t.toFixed(2)}
            </text>
          </g>
        ))}
        {/* dots */}
        {values.map((v, i) => (
          v != null && Number.isFinite(v) ? (
            <circle
              key={`d-${i}`}
              cx={xOf(v)}
              cy={y}
              r={4}
              fill={color}
              stroke="#FFFFFF"
              strokeWidth={1.2}
              opacity={0.85}
            />
          ) : null
        ))}
      </svg>
    </div>
  );
}

const REC_COLOURS: Record<string, { fill: string; stroke: string; label: string }> = {
  cell_cycle: { fill: "#F5E5D4", stroke: "#BC6B2E", label: "cell_cycle" },
  p16:        { fill: "#FBF0CC", stroke: "#B8860B", label: "p16" },
  alternate:  { fill: "#E6F0F3", stroke: "#3A6B7E", label: "alternate" },
  passengers: { fill: "#EDEDED", stroke: "#9AA0A6", label: "passengers" },
};

function StabilityRecurrence({
  recurring,
  passengers,
  totalSeeds,
}: {
  recurring: { symbol: string; count: number; tag: string }[];
  passengers: { symbol: string; count: number; tag: string }[];
  totalSeeds: number;
}) {
  return (
    <div>
      <div className="text-[11.5px] font-semibold text-ink">
        Do the same genes keep coming back?
      </div>
      <p className="mt-0.5 text-[11px] italic text-muted">
        Recurring proliferation / cell-cycle genes = real signal.
        Genes that appear only once are usually passengers.
      </p>
      <div className="mt-2 space-y-1">
        {recurring.length === 0 && (
          <p className="text-[11px] italic text-muted">
            No gene appeared in more than one seed&rsquo;s winner.
          </p>
        )}
        {recurring.map((r) => {
          const pal = REC_COLOURS[r.tag] ?? REC_COLOURS.alternate;
          const pct = Math.round((r.count / Math.max(1, totalSeeds)) * 100);
          return (
            <div
              key={r.symbol}
              className="flex items-center gap-2 text-[11.5px] text-ink"
            >
              <span className="w-20 font-mono text-ink">{r.symbol}</span>
              <div className="relative h-2 w-full max-w-[260px] flex-1 overflow-hidden rounded-sm bg-card">
                <div
                  style={{
                    width: `${pct}%`,
                    height: "100%",
                    background: pal.fill,
                    borderRight: `1px solid ${pal.stroke}`,
                    transition: "width 200ms ease",
                  }}
                />
              </div>
              <span className="font-mono text-muted">
                {r.count} / {totalSeeds}
              </span>
              <span
                className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
                style={{
                  color: pal.stroke,
                  backgroundColor: pal.fill,
                  border: `1px solid ${pal.stroke}`,
                }}
              >
                {r.tag === "alternate" ? "alternate" : r.tag}
              </span>
            </div>
          );
        })}
        {passengers.length > 0 && (
          <div className="mt-1 flex items-center gap-2 text-[11.5px] text-muted">
            <span className="w-20 font-mono">passengers</span>
            <div className="relative h-2 w-full max-w-[260px] flex-1 overflow-hidden rounded-sm bg-card">
              <div
                style={{
                  width: `${Math.round((1 / Math.max(1, totalSeeds)) * 100)}%`,
                  height: "100%",
                  background: REC_COLOURS.passengers.fill,
                  borderRight: `1px solid ${REC_COLOURS.passengers.stroke}`,
                }}
              />
            </div>
            <span className="font-mono">1 / {totalSeeds}</span>
            <span
              className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
              style={{
                color: REC_COLOURS.passengers.stroke,
                backgroundColor: REC_COLOURS.passengers.fill,
                border: `1px solid ${REC_COLOURS.passengers.stroke}`,
              }}
              title={
                passengers
                  .slice(0, 40)
                  .map((p) => p.symbol)
                  .join(", ")
                + (passengers.length > 40 ? ", …" : "")
              }
            >
              {passengers.length} gene{passengers.length === 1 ? "" : "s"} · 1 seed each
            </span>
          </div>
        )}
      </div>
    </div>
  );
}

function StabilityTable({ rows }: { rows: SeedRow[] }) {
  return (
    <div>
      <div className="text-[11.5px] font-semibold text-ink">
        Per-seed detail
      </div>
      <div className="mt-2 overflow-hidden rounded-md border border-border">
        <table className="w-full text-xs">
          <thead className="bg-card text-[10px] tracking-wide text-muted">
            <tr>
              <th className="px-3 py-1.5 text-left">seed</th>
              <th className="px-3 py-1.5 text-right">held-out AUROC</th>
              <th className="px-3 py-1.5 text-right">p</th>
              <th className="px-3 py-1.5 text-right">indep. AUROC</th>
              <th className="px-3 py-1.5 text-right">indep. p</th>
              <th className="px-3 py-1.5 text-right">genes found / total</th>
            </tr>
          </thead>
          <tbody>
            {rows.map((r) => {
              const t = r.transfer;
              const total = t ? t.n_found + t.n_missing : r.symbols.length;
              const found = t ? t.n_found : r.symbols.length;
              return (
                <tr key={r.seed} className="border-t border-border text-ink">
                  <td className="px-3 py-1.5 font-mono">{r.seed}</td>
                  <td className="px-3 py-1.5 text-right font-mono">
                    {fmtFit(r.holdout, 3)}
                  </td>
                  <td className="px-3 py-1.5 text-right font-mono text-muted">
                    {fmtFit(r.permutation_p, 3)}
                  </td>
                  <td className="px-3 py-1.5 text-right font-mono">
                    {fmtFit(t?.auroc ?? null, 3)}
                  </td>
                  <td className="px-3 py-1.5 text-right font-mono text-muted">
                    {fmtFit(t?.p ?? null, 3)}
                  </td>
                  <td className="px-3 py-1.5 text-right font-mono text-muted">
                    {r.error ? (
                      <span className="text-highlight" title={r.error}>error</span>
                    ) : (
                      `${found} / ${total || "—"}`
                    )}
                  </td>
                </tr>
              );
            })}
          </tbody>
        </table>
      </div>
    </div>
  );
}


function Metric({
  label,
  value,
  tip,
  sub,
}: {
  label: string;
  value: string;
  tip?: string;
  sub?: string;
}) {
  return (
    <div className="rounded-md border border-border bg-card px-3 py-2">
      <div className="flex items-center text-[10px] tracking-wide text-muted">
        {label}
        {tip && <InfoTip text={tip} label={`About ${label}`} />}
      </div>
      <div className="mt-1 text-xl font-semibold text-ink">{value}</div>
      {sub && (
        <div className="mt-0.5 text-[10px] italic text-muted">{sub}</div>
      )}
    </div>
  );
}

// ---------------------------------------------------------------------------
// RankingResult — the consolidated reveal panel for supervised
// objectives. Shows the FULL single-gene ranking computed on the
// engine's TRAIN split (~20k opaque rows), with the GP winner gene(s)
// and the dataset's reference-set genes marked along a rank track and
// pinned to the top of a browsable list. Symbols are revealed ONLY for
// those highlighted rows via the single-ID /evaluate reveal path.
// Airgap-clean: the full list never carries symbols on the wire.
// ---------------------------------------------------------------------------

// Three-way colour scheme for the ranking result. The winner stays in
// the project's accent teal; reference sets get their own distinct
// colours so e.g. p16 (a single canonical marker) reads separately
// from the broader cell-cycle group. Falls back to the muted-amber
// "set" colour for any future set name.
interface RankPalette {
  stroke: string;
  fill: string;
  bg: string;
}

const RANK_COLORS: Record<string, RankPalette> & {
  winner: RankPalette;
  p16: RankPalette;
  immune: RankPalette;
  cell_cycle: RankPalette;
  MMR: RankPalette;
} = {
  winner: { stroke: "#3A6B7E", fill: "#3A6B7E", bg: "#EAF0F2" },
  // p16 / immune — the "single canonical marker" slot — deep gold.
  p16:        { stroke: "#B8860B", fill: "#B8860B", bg: "#FBF0CC" },
  immune:     { stroke: "#B8860B", fill: "#B8860B", bg: "#FBF0CC" },
  // cell_cycle / MMR — the "broader set" slot — muted amber.
  cell_cycle: { stroke: "#A75D2A", fill: "#D9A074", bg: "#FBF1E6" },
  MMR:        { stroke: "#A75D2A", fill: "#D9A074", bg: "#FBF1E6" },
};

function colorForSet(setName: string | undefined): RankPalette {
  if (!setName) return RANK_COLORS.cell_cycle;
  return RANK_COLORS[setName] ?? RANK_COLORS.cell_cycle;
}

// "top 0.5%" / "top 21%" / "bottom 4.9%" — two sig figs. Use "top X%"
// for ranks in the upper half; flip to "bottom Y%" once past the
// midpoint so very-poor ranks read as low.
function rankPercentile(rank: number, n: number): string {
  if (n <= 0) return "—";
  const topPct = (rank / n) * 100;
  if (topPct <= 50) return `top ${formatPct(topPct)}%`;
  const bottomPct = 100 - topPct;
  return `bottom ${formatPct(bottomPct)}%`;
}

function formatPct(p: number): string {
  // 2 sig figs across the whole range.
  if (p >= 10) return p.toFixed(0);
  if (p >= 1) return p.toFixed(1);
  if (p >= 0.1) return p.toFixed(2);
  return p.toFixed(3);
}

interface RevealedWinnerRow {
  opaque_id: string;
  symbol: string;
  rank: number | null;
  score: number | null;
}

function RankingResult({
  result,
  dataset,
  target,
  onRevealed,
}: {
  result: RunResult;
  dataset: DatasetId;
  target: Target;
  onRevealed?: (r: EvaluateResponse) => void;
}) {
  const [diag, setDiag] = useState<FullRankDiagnostic | null>(null);
  const [diagError, setDiagError] = useState<string | null>(null);
  const [diagLoading, setDiagLoading] = useState(false);

  const [winnerReveal, setWinnerReveal] = useState<RevealedWinnerRow[]>([]);
  const [refKey, setRefKey] = useState<string>("");
  const [search, setSearch] = useState<string>("");

  // Fetch the full opaque ranking once per (dataset, target).
  useEffect(() => {
    let cancelled = false;
    setDiagLoading(true);
    setDiagError(null);
    setDiag(null);
    getFullRankDiagnostic(dataset, target)
      .then((d) => {
        if (!cancelled) setDiag(d);
      })
      .catch((e) => {
        if (!cancelled)
          setDiagError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (!cancelled) setDiagLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [dataset, target]);

  // Build a map from opaque_id → rank/score from the full diagnostic.
  const rankByOpaque = useMemo(() => {
    const m = new Map<string, { rank: number; score: number }>();
    if (diag) {
      for (const r of diag.ranks)
        m.set(r.opaque_id, { rank: r.rank, score: r.score });
    }
    return m;
  }, [diag]);

  // Reveal the winner's opaque IDs → symbols, plus look up their rank
  // in the diagnostic for the rank-track marks. Uses the single-ID
  // /evaluate path — never the whole map.
  const winnerIds = result.winning.gene_ids;
  const refKeysForDataset = useMemo(
    () => DATASET_REGISTRY[dataset].refSetKeys,
    [dataset],
  );
  useEffect(() => {
    if (refKey === "" || !refKeysForDataset.includes(refKey as never)) {
      setRefKey(refKeysForDataset[0] as string);
    }
  }, [refKeysForDataset, refKey]);

  useEffect(() => {
    let cancelled = false;
    if (!winnerIds.length || !diag) {
      setWinnerReveal([]);
      return;
    }
    const referenceSet = (refKey || refKeysForDataset[0]) as string;
    postEvaluate({
      gene_ids: winnerIds,
      reference_set: referenceSet,
      dataset,
      target,
    })
      .then((r) => {
        if (cancelled) return;
        const rows: RevealedWinnerRow[] = r.revealed.map((row) => {
          const lookup = rankByOpaque.get(row.id);
          return {
            opaque_id: row.id,
            symbol: row.symbol,
            rank: lookup?.rank ?? row.rank ?? null,
            score:
              lookup?.score ??
              (row.single_gene_metric != null
                ? row.single_gene_metric
                : null),
          };
        });
        setWinnerReveal(rows);
        if (onRevealed) onRevealed(r);
      })
      .catch(() => {
        if (!cancelled) setWinnerReveal([]);
      });
    return () => {
      cancelled = true;
    };
  }, [winnerIds, diag, dataset, target, refKey, rankByOpaque, onRevealed, refKeysForDataset]);

  if (diagLoading && !diag) {
    return (
      <SectionCard title="Ranking" subtitle="Single-gene ranking on the training split.">
        <p className="text-xs text-muted">Computing the full ranking…</p>
      </SectionCard>
    );
  }
  if (diagError || !diag) {
    return (
      <SectionCard title="Ranking" subtitle="Single-gene ranking on the training split.">
        <p className="rounded-md border border-highlight/40 bg-highlight/10 px-3 py-2 text-xs text-highlight">
          {diagError ?? "No ranking available for this (dataset, target)."}
        </p>
      </SectionCard>
    );
  }

  const winnerOpaqueSet = new Set(winnerIds);
  const winnerSymbolByOpaque = new Map(
    winnerReveal.map((w) => [w.opaque_id, w.symbol]),
  );
  const referenceByOpaque = new Map<string, ReferenceMark>();
  for (const m of diag.reference_marks) referenceByOpaque.set(m.opaque_id, m);

  const metricLabel =
    diag.metric_kind === "auroc" ? "AUROC" : "signed Spearman ρ";

  // Pinned rows: known reference markers only (p16 / cell_cycle for
  // HNSC; MMR / immune for CRC). The winner's constituent genes used
  // to be pinned here too, but that mixed the GP's GROUP into a table
  // of INDIVIDUALLY-scored reference markers — confusing. Winner
  // genes + their ranks now live in the Result panel itself
  // (self-contained "GP's group" view); this panel is the
  // known-marker recovery diagnostic.
  const pinnedRows: {
    opaque_id: string;
    symbol: string;
    rank: number;
    score: number;
    kind: "reference";
    set_name?: string;
  }[] = [];
  for (const m of [...diag.reference_marks].sort((a, b) => a.rank - b.rank)) {
    pinnedRows.push({
      opaque_id: m.opaque_id,
      symbol: m.symbol,
      rank: m.rank,
      score: m.score,
      kind: "reference",
      set_name: m.set_name,
    });
  }

  // Browsable list: top-200 of the ranks PLUS any rows that match the
  // search filter — pin the highlighted rows above. (Anti-pattern to
  // render 20k <tr>s; this is more than enough for browsing.)
  const TOP_N = 200;
  const trimmedSearch = search.trim().toLowerCase();
  let listRows: FullRankDiagnostic["ranks"];
  if (trimmedSearch.length > 0) {
    listRows = diag.ranks.filter((r) =>
      r.opaque_id.toLowerCase().includes(trimmedSearch),
    );
  } else {
    listRows = diag.ranks.slice(0, TOP_N);
  }

  return (
    <SectionCard
      title="Known-marker recovery"
      subtitle={
        `Where the textbook ${
          target === "hpv" ? "HPV" : "MSI"
        } genes land on their own. The engine never sees this — ` +
        `it's how we check whether the known biology is even findable ` +
        `in the data. Ranked across all ` +
        `${diag.n_genes.toLocaleString()} genes on the ` +
        `${diag.n_samples}-patient training split.` +
        (diag.n_pos > 0
          ? ` (${
              target === "hpv" ? "HPV+" : "MSI-H"
            } ${diag.n_pos} / ${
              target === "hpv" ? "HPV−" : "MSS"
            } ${diag.n_neg}.)`
          : "")
      }
      titleTip={TIPS.rankingResult}
    >
      <RankTrack
        diag={diag}
        winnerOpaqueIds={[]}
        winnerReveal={[]}
      />

      {/* Pinned highlighted rows */}
      <div className="mt-4">
        <div className="flex items-center text-xs tracking-wide text-muted">
          Known markers
          <InfoTip
            text={TIPS.rankingHighlighted}
            label="About the highlighted rows"
          />
        </div>
        <div className="mt-2 overflow-hidden rounded-md border border-border">
          <table className="w-full text-sm">
            <thead className="bg-card text-xs tracking-wide text-muted">
              <tr>
                <th className="px-3 py-2 text-left">Source</th>
                <th className="px-3 py-2 text-left">Symbol</th>
                <th className="px-3 py-2 text-left">Gene code</th>
                <th className="px-3 py-2 text-right">Rank / N</th>
                <th className="px-3 py-2 text-right">Percentile</th>
                <th className="px-3 py-2 text-right">{metricLabel}</th>
              </tr>
            </thead>
            <tbody>
              {pinnedRows.length === 0 && (
                <tr>
                  <td colSpan={6} className="px-3 py-3 text-xs italic text-muted">
                    No reference markers configured for this (dataset, target).
                  </td>
                </tr>
              )}
              {pinnedRows.map((r) => {
                const palette = colorForSet(r.set_name);
                const label = r.set_name ?? "reference";
                return (
                  <tr
                    key={`pin-${r.opaque_id}`}
                    style={{ backgroundColor: palette.bg }}
                    className="text-ink"
                  >
                    <td className="px-3 py-2">
                      <span
                        className="font-semibold"
                        style={{ color: palette.stroke }}
                      >
                        {label}
                      </span>
                    </td>
                    <td
                      className="px-3 py-2 font-semibold"
                      style={{ color: palette.stroke }}
                    >
                      {r.symbol}
                    </td>
                    <td className="px-3 py-2 font-mono text-xs">{r.opaque_id}</td>
                    <td className="px-3 py-2 text-right font-mono text-xs">
                      {r.rank.toLocaleString()} / {diag.n_genes.toLocaleString()}
                    </td>
                    <td className="px-3 py-2 text-right font-mono text-xs text-muted">
                      {rankPercentile(r.rank, diag.n_genes)}
                    </td>
                    <td className="px-3 py-2 text-right font-mono text-xs">
                      {fmtFit(r.score, 3)}
                    </td>
                  </tr>
                );
              })}
            </tbody>
          </table>
        </div>
        <p className="mt-2 text-[11px] italic text-muted">
          A recovery diagnostic — where each <span className="font-semibold">known marker</span>{" "}
          (p16, cell_cycle for HNSC; MMR, immune for CRC) ranks as a
          single-gene separator. The winning program (its genes, its
          group score, its confound survival) lives in the Result
          panel above.
        </p>
      </div>

      {/* Raw-anonymous-ranking — collapsed disclosure */}
      <RawRankingDisclosure
        diag={diag}
        search={search}
        setSearch={setSearch}
        winnerOpaqueSet={winnerOpaqueSet}
        winnerSymbolByOpaque={winnerSymbolByOpaque}
        referenceByOpaque={referenceByOpaque}
        listRows={listRows}
        trimmedSearch={trimmedSearch}
        topN={TOP_N}
        metricLabel={metricLabel}
      />

      <p className="mt-4 rounded-md border border-border bg-bg p-3 text-[11.5px] italic leading-relaxed text-muted">
        The genetic-programming search isn&rsquo;t an exhaustive top-1
        ranker — it&rsquo;s stochastic. The diagnostic ranking scores
        all{" "}~{diag.n_genes.toLocaleString()} genes and sorts them;
        genetic programming does not. It samples programs, mutates,
        recombines, and keeps
        what survives tournaments — exploring only a subset of the
        space. So it finds an excellent solution, not provably the best
        one: it may never have tried the rank-1 gene, or tried it in a
        generation that got out-competed, and settled into a
        neighbouring solution. Evolution finds a very good answer, not
        the global maximum.
      </p>
    </SectionCard>
  );
}

// Collapsed disclosure for the raw opaque-ID ranking. Only purpose
// is to demonstrate the ranking IS computed blind — the analytic
// content lives in the highlighted table + the rank chart.
function RawRankingDisclosure({
  diag,
  search,
  setSearch,
  winnerOpaqueSet,
  winnerSymbolByOpaque,
  referenceByOpaque,
  listRows,
  trimmedSearch,
  topN,
  metricLabel,
}: {
  diag: FullRankDiagnostic;
  search: string;
  setSearch: (s: string) => void;
  winnerOpaqueSet: Set<string>;
  winnerSymbolByOpaque: Map<string, string>;
  referenceByOpaque: Map<string, ReferenceMark>;
  listRows: FullRankDiagnostic["ranks"];
  trimmedSearch: string;
  topN: number;
  metricLabel: string;
}) {
  const [open, setOpen] = useState(false);
  const panelId = useId();
  return (
    <div className="mt-4 text-xs">
      <button
        type="button"
        onClick={() => setOpen((o) => !o)}
        aria-expanded={open}
        aria-controls={panelId}
        className="inline-flex items-center gap-1.5 rounded text-ink hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent"
      >
        <span
          aria-hidden
          style={{
            display: "inline-block",
            transition: "transform 120ms ease",
            transform: open ? "rotate(90deg)" : "rotate(0deg)",
          }}
        >
          ▸
        </span>
        <span className="font-medium">
          Raw anonymous ranking — proof the ranking is computed blind
        </span>
      </button>
      {open && (
        <div id={panelId} className="mt-3">
          <p className="mb-2 text-[11px] italic text-muted">
            The ranking above is computed only on the anonymous gene
            codes. The full list below stays anonymous on purpose —
            revealing every name would defeat the whole blind-search
            point. This view is just to show that the ranking really
            is blind.
          </p>
          <div className="mb-2 flex items-center gap-3">
            <span className="text-xs tracking-wide text-muted">
              Full ranking (browse by gene code)
            </span>
            <input
              type="text"
              placeholder="search gene codes (e.g. g03521)…"
              value={search}
              onChange={(e) => setSearch(e.target.value)}
              className="ml-auto w-56 rounded-md border border-border bg-white px-2 py-1 text-xs focus:border-accent focus:outline-none"
            />
          </div>
          <div className="overflow-hidden rounded-md border border-border">
            <div className="max-h-72 overflow-y-auto">
              <table className="w-full text-xs">
                <thead className="sticky top-0 z-10 bg-card text-[10px] tracking-wide text-muted">
                  <tr>
                    <th className="px-3 py-1.5 text-left">Rank</th>
                    <th className="px-3 py-1.5 text-left">Gene code</th>
                    <th className="px-3 py-1.5 text-right">{metricLabel}</th>
                  </tr>
                </thead>
                <tbody>
                  {listRows.map((r) => {
                    const isWinner = winnerOpaqueSet.has(r.opaque_id);
                    const refMark = referenceByOpaque.get(r.opaque_id);
                    const palette = isWinner
                      ? RANK_COLORS.winner
                      : refMark
                        ? colorForSet(refMark.set_name)
                        : null;
                    return (
                      <tr
                        key={r.opaque_id}
                        className="border-t border-border"
                        style={{
                          backgroundColor: palette?.bg ?? undefined,
                        }}
                      >
                        <td className="px-3 py-1 font-mono text-ink">
                          {r.rank.toLocaleString()}
                        </td>
                        <td className="px-3 py-1 font-mono">
                          <span className="text-ink">{r.opaque_id}</span>
                          {isWinner && (
                            <span
                              className="ml-2 text-[10px] font-semibold"
                              style={{ color: RANK_COLORS.winner.stroke }}
                            >
                              ← winner (
                              {winnerSymbolByOpaque.get(r.opaque_id) ?? "…"})
                            </span>
                          )}
                          {!isWinner && refMark && (
                            <span
                              className="ml-2 text-[10px] font-semibold"
                              style={{
                                color: colorForSet(refMark.set_name).stroke,
                              }}
                            >
                              ← {refMark.set_name}: {refMark.symbol}
                            </span>
                          )}
                        </td>
                        <td className="px-3 py-1 text-right font-mono text-ink">
                          {fmtFit(r.score, 3)}
                        </td>
                      </tr>
                    );
                  })}
                  {listRows.length === 0 && (
                    <tr>
                      <td
                        colSpan={3}
                        className="px-3 py-3 text-xs italic text-muted"
                      >
                        No matches for &ldquo;{search}&rdquo;.
                      </td>
                    </tr>
                  )}
                </tbody>
              </table>
            </div>
            {trimmedSearch.length === 0 && (
              <p className="border-t border-border bg-card px-3 py-1.5 text-[11px] text-muted">
                Showing the top {topN} of {diag.n_genes.toLocaleString()}. Use
                the search box to find any gene code.
              </p>
            )}
          </div>
        </div>
      )}
    </div>
  );
}

// Where the winner and known markers rank (1 = best single-gene
// separator, log scale). Log-rank axis spreads the top cluster out;
// each highlighted gene is a labelled dot, coloured by its source
// (winner / p16 / cell_cycle, or winner / MMR / immune for CRC).
// Labels stagger vertically with leader lines so they don't overlap.
function RankTrack({
  diag,
  winnerOpaqueIds,
  winnerReveal,
}: {
  diag: FullRankDiagnostic;
  winnerOpaqueIds: string[];
  winnerReveal: RevealedWinnerRow[];
}) {
  const W = 760;
  const padL = 56;
  const padR = 40;
  const padTop = 30;
  const padBot = 40;
  const innerW = W - padL - padR;

  const labelByOpaque = new Map(
    winnerReveal.map((w) => [w.opaque_id, w.symbol]),
  );
  const rankByOpaque = new Map(diag.ranks.map((r) => [r.opaque_id, r.rank]));

  // Collect every highlighted point (winner + reference) with its
  // colour palette and source label.
  type Mark = {
    key: string;
    symbol: string;
    rank: number;
    palette: RankPalette;
    kind: "winner" | "reference";
    set_name?: string;
  };
  const marks: Mark[] = [];
  for (const id of winnerOpaqueIds) {
    const rank = rankByOpaque.get(id);
    if (rank == null) continue;
    marks.push({
      key: `w-${id}`,
      symbol: labelByOpaque.get(id) ?? "…",
      rank,
      palette: RANK_COLORS.winner,
      kind: "winner",
    });
  }
  const winnerOpaqueSet = new Set(winnerOpaqueIds);
  for (const m of diag.reference_marks) {
    if (winnerOpaqueSet.has(m.opaque_id)) continue;
    marks.push({
      key: `r-${m.opaque_id}`,
      symbol: m.symbol,
      rank: m.rank,
      palette: colorForSet(m.set_name),
      kind: "reference",
      set_name: m.set_name,
    });
  }
  // Sort by rank ascending so labels stagger predictably left → right.
  marks.sort((a, b) => a.rank - b.rank);

  // Log scale: x = padL + (log10(rank) / log10(N)) * innerW.
  const logN = Math.log10(Math.max(2, diag.n_genes));
  function rankToX(r: number): number {
    const lr = Math.log10(Math.max(1, r));
    return padL + (lr / logN) * innerW;
  }

  // Gridline ticks at 1, 10, 100, 1k, 10k, N (skipping ones too close
  // to N).
  const tickRanks: number[] = [1, 10, 100, 1000, 10000].filter(
    (t) => t < diag.n_genes,
  );
  tickRanks.push(diag.n_genes);
  const tickLabels: Record<number, string> = {
    1: "1",
    10: "10",
    100: "100",
    1000: "1k",
    10000: "10k",
  };
  tickLabels[diag.n_genes] = diag.n_genes.toLocaleString();

  // Collision-free label placement. Each label is `textAnchor="middle"`
  // around its dot's x position, so its bounding box spans
  // [x - w/2, x + w/2] where w ≈ text.length × fontSize × 0.6.
  // Walk marks left-to-right (they're already sorted by rank) and
  // greedily place each label in the LOWEST row whose previous
  // label's right edge + a min gap is ≤ this label's left edge; if
  // no row fits, open a new one. No fixed cap — the SVG height
  // expands so nothing clips. With this in place the MCM /
  // AURKB / CCNB1 clusters render cleanly even when several genes
  // share nearby ranks.
  const LABEL_FONT_SIZE = 10;
  const LABEL_CHAR_W = LABEL_FONT_SIZE * 0.6;
  const LABEL_GAP_PX = 6;
  const ROW_GAP = 14;
  const rowLastRight: number[] = [];
  const rowOfLabel: number[] = new Array(marks.length).fill(0);
  marks.forEach((m, i) => {
    const x = rankToX(m.rank);
    const w = Math.max(LABEL_FONT_SIZE, m.symbol.length * LABEL_CHAR_W);
    const left = x - w / 2;
    const right = x + w / 2;
    let chosen = -1;
    for (let r = 0; r < rowLastRight.length; r++) {
      if (rowLastRight[r] + LABEL_GAP_PX <= left) {
        chosen = r;
        break;
      }
    }
    if (chosen === -1) {
      chosen = rowLastRight.length;
      rowLastRight.push(right);
    } else {
      rowLastRight[chosen] = right;
    }
    rowOfLabel[i] = chosen;
  });
  const numRows = Math.max(1, rowLastRight.length);
  // Labels stack from padTop downward; the axis sits below the
  // stack with a small breathing gap so leader lines from the
  // top-row labels are clearly readable.
  const LABEL_TO_AXIS_GAP = 24;
  const labelStackH = numRows * ROW_GAP;
  const axisY = padTop + labelStackH + LABEL_TO_AXIS_GAP;
  const H = axisY + padBot;
  // Convert row index → label baseline y (one row below the top).
  const labelY: number[] = rowOfLabel.map(
    (r) => padTop + (r + 1) * ROW_GAP,
  );

  const showWinner = winnerOpaqueIds.length > 0;
  const legendItems = [
    ...(showWinner
      ? [{ label: "winner", palette: RANK_COLORS.winner }]
      : []),
    ...Array.from(
      new Set(diag.reference_marks.map((m) => m.set_name)),
    ).map((name) => ({ label: name, palette: colorForSet(name) })),
  ];

  return (
    <div className="mt-2 w-full">
      <div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
        <h3 className="text-sm font-medium text-ink">
          {showWinner
            ? "Where the winner and known markers rank"
            : "Where the known markers rank"}
          <span className="ml-2 text-[11px] font-normal text-muted">
            (1 = best single-gene separator)
          </span>
        </h3>
        <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted">
          {legendItems.map((it) => (
            <span key={it.label} className="flex items-center gap-1.5">
              <span
                aria-hidden
                style={{
                  display: "inline-block",
                  width: 10,
                  height: 10,
                  borderRadius: 999,
                  background: it.palette.fill,
                  border: `1.5px solid ${it.palette.stroke}`,
                }}
              />
              <span style={{ color: it.palette.stroke }}>{it.label}</span>
            </span>
          ))}
        </div>
      </div>
      <svg
        viewBox={`0 0 ${W} ${H}`}
        width="100%"
        height={H}
        role="img"
        aria-label="Single-gene rank chart (log scale)"
        style={{ display: "block" }}
      >
        {/* gridlines + tick labels */}
        {tickRanks.map((t) => {
          const x = rankToX(t);
          return (
            <g key={`tick-${t}`}>
              <line
                x1={x}
                y1={padTop - 4}
                x2={x}
                y2={axisY}
                stroke="#ECEAE4"
                strokeWidth={1}
              />
              <text
                x={x}
                y={axisY + 16}
                fontSize={10}
                fill="#6E7F8C"
                textAnchor="middle"
              >
                {tickLabels[t]}
              </text>
            </g>
          );
        })}
        {/* axis line */}
        <line
          x1={padL}
          y1={axisY}
          x2={W - padR}
          y2={axisY}
          stroke="#B9B6AE"
          strokeWidth={1.5}
        />
        <text x={padL} y={axisY + 32} fontSize={10} fill="#6E7F8C">
          best single-gene separator →
        </text>
        <text
          x={W - padR}
          y={axisY + 32}
          fontSize={10}
          fill="#6E7F8C"
          textAnchor="end"
        >
          ← worst
        </text>

        {/* highlighted points: lollipop with leader line + dot + label */}
        {marks.map((m, i) => {
          const x = rankToX(m.rank);
          const ly = labelY[i];
          return (
            <g key={m.key}>
              <line
                x1={x}
                y1={ly + 4}
                x2={x}
                y2={axisY}
                stroke={m.palette.stroke}
                strokeWidth={1}
                strokeOpacity={0.45}
              />
              <circle
                cx={x}
                cy={axisY}
                r={m.kind === "winner" ? 5 : 4}
                fill={m.palette.fill}
                stroke={m.palette.stroke}
                strokeWidth={1.5}
              />
              <text
                x={x}
                y={ly}
                fontSize={10}
                fontWeight={m.kind === "winner" ? 700 : 600}
                fill={m.palette.stroke}
                textAnchor="middle"
              >
                {m.symbol}
              </text>
            </g>
          );
        })}
      </svg>
      <p className="mt-1 text-[11px] italic text-muted">
        Each dot is one known-marker gene at its single-gene rank on the
        engine&rsquo;s training split. Log axis stretches the top so a
        recoverable marker reads cleanly; weak ones sit at the right.
        A marker near rank 1 means the canonical biology is findable
        from expression; a marker far down the axis means it only
        helps in combination.
      </p>
    </div>
  );
}

function Evaluator({
  geneIds,
  fitnessLabel,
  dataset,
  target,
  onRevealed,
}: {
  geneIds: string[];
  fitnessLabel: string;
  dataset: DatasetId;
  target: Target;
  onRevealed?: (r: EvaluateResponse) => void;
}) {
  const refKeys = DATASET_REGISTRY[dataset].refSetKeys;
  const [ref, setRef] = useState<ReferenceSetKey>(refKeys[0]);

  // Switching cancers swaps the available reference sets — keep `ref`
  // valid for the active dataset.
  useEffect(() => {
    if (!refKeys.includes(ref)) {
      setRef(refKeys[0]);
    }
  }, [refKeys, ref]);

  // ``data`` is the raw last /evaluate response. We re-derive overlap
  // locally against the toggled reference set, so switching MMR ↔ immune
  // is instant and never needs another API round-trip.
  const [data, setData] = useState<EvaluateResponse | null>(null);
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // TMB-rank diagnostic lifted from TMBRankPanel so its data also drives
  // the MLH1 success callout and the OverlapSummary's muted secondary
  // sentence. One fetch, three consumers. Colorectal-only.
  const [diagnostic, setDiagnostic] = useState<TMBRankDiagnostic | null>(null);
  const [diagError, setDiagError] = useState<string | null>(null);
  const [diagLoading, setDiagLoading] = useState(false);
  useEffect(() => {
    if (dataset !== "coadread" || target !== "tmb") {
      setDiagnostic(null);
      setDiagError(null);
      setDiagLoading(false);
      return;
    }
    let cancelled = false;
    setDiagLoading(true);
    setDiagError(null);
    getTMBRankDiagnostic()
      .then((d) => {
        if (!cancelled) setDiagnostic(d);
      })
      .catch((e) => {
        if (!cancelled)
          setDiagError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (!cancelled) setDiagLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [dataset, target]);

  // HPV-rank diagnostic — sibling of TMB-rank, gated to HNSC + HPV.
  const [hpvDiag, setHpvDiag] = useState<HPVRankDiagnostic | null>(null);
  const [hpvDiagError, setHpvDiagError] = useState<string | null>(null);
  const [hpvDiagLoading, setHpvDiagLoading] = useState(false);
  useEffect(() => {
    if (dataset !== "hnsc" || target !== "hpv") {
      setHpvDiag(null);
      setHpvDiagError(null);
      setHpvDiagLoading(false);
      return;
    }
    let cancelled = false;
    setHpvDiagLoading(true);
    setHpvDiagError(null);
    getHPVRankDiagnostic()
      .then((d) => {
        if (!cancelled) setHpvDiag(d);
      })
      .catch((e) => {
        if (!cancelled)
          setHpvDiagError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (!cancelled) setHpvDiagLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [dataset, target]);
  const mlh1Row = useMemo<GeneRankRow | undefined>(() => {
    if (!diagnostic) return undefined;
    return diagnostic.mmr.find((r) => r.symbol === "MLH1");
  }, [diagnostic]);

  async function go() {
    setBusy(true);
    setError(null);
    try {
      const r = await postEvaluate({
        gene_ids: geneIds,
        reference_set: ref,
        dataset,
        target,
      });
      setData(r);
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally {
      setBusy(false);
    }
  }

  // Derive matches locally so the toggle always changes something —
  // even when overlap is 0 the visible reference list, the count line,
  // and the table rows all update.
  const effectiveData = useMemo<EvaluateResponse | null>(() => {
    if (!data) return null;
    const datasetSets = REFERENCE_SETS_BY_DATASET[dataset] as Record<
      string,
      readonly string[]
    >;
    const memberSet = new Set<string>(datasetSets[ref] ?? []);
    const revealed = data.revealed.map((row) => ({
      ...row,
      matched: memberSet.has(row.symbol),
    }));
    return {
      revealed,
      overlap_count: revealed.filter((r) => r.matched).length,
      reference_set: ref,
    };
  }, [data, ref, dataset]);

  // Push the re-derived matches up to Lab so the ProgramGraph's Select
  // nodes re-tint the matched gene pills on toggle as well.
  useEffect(() => {
    if (effectiveData && onRevealed) onRevealed(effectiveData);
  }, [effectiveData, onRevealed]);

  const sortedRows = useMemo(() => {
    if (!effectiveData) return null;
    return [...effectiveData.revealed].sort(
      (a, b) => Number(b.matched) - Number(a.matched),
    );
  }, [effectiveData]);

  const matchedSymbols = useMemo(
    () => effectiveData?.revealed.filter((r) => r.matched).map((r) => r.symbol) ?? [],
    [effectiveData],
  );

  return (
    <SectionCard
      title="Reveal & evaluate"
      subtitle="Now that the search is over, unmask the gene codes and compare the engine's picks to the textbook markers."
    >
      <div className="flex flex-col items-start gap-3 sm:flex-row sm:items-center">
        {target !== "none" && (
          <div className="flex items-center gap-2">
            <span className="flex items-center text-xs font-medium text-muted">
              reference set
              <InfoTip
                text={REFERENCE_SET_TIP_BY_DATASET[dataset]}
                label="About the reference set"
              />
            </span>
            {refKeys.map((r) => (
              <button
                key={r}
                type="button"
                onClick={() => setRef(r)}
                aria-pressed={ref === r}
                className={[
                  "rounded-md px-3 py-1.5 text-sm transition-colors",
                  ref === r
                    ? "bg-ink text-card"
                    : "border border-border bg-card text-ink hover:border-accent/40",
                ].join(" ")}
              >
                {r}
              </button>
            ))}
          </div>
        )}
        <div className="flex items-center">
          <button
            type="button"
            onClick={go}
            disabled={busy}
            className={[
              "rounded-md px-4 py-1.5 text-sm font-medium",
              busy
                ? "cursor-not-allowed bg-muted text-card"
                : "bg-accent text-card hover:bg-ink",
            ].join(" ")}
          >
            {busy ? "Revealing…" : "Reveal & evaluate"}
          </button>
          <InfoTip
            text={TIPS.revealEvaluateButton}
            label="About Reveal & evaluate"
          />
        </div>
        <span className="text-xs text-muted">objective: {fitnessLabel}</span>
      </div>

      {target !== "none" && (
        <div className="mt-3 text-xs text-muted">
          Checking the winning genes against this set:{" "}
          <span className="font-mono text-ink">
            {(
              REFERENCE_SETS_BY_DATASET[dataset] as Record<string, readonly string[]>
            )[ref]?.join(", ") ?? ""}
          </span>
        </div>
      )}
      {target === "none" && (
        <p className="mt-3 text-xs italic text-muted">
          Unsupervised run — no reference set; the post-hoc alignment (above,
          in Result) shows how the discovered split lines up with the
          named label this cohort carries.
        </p>
      )}

      {error && (
        <p className="mt-3 rounded-md border border-highlight/40 bg-highlight/10 px-3 py-2 text-xs text-highlight">
          {error}
        </p>
      )}

      {dataset === "coadread" && target === "tmb" && mlh1Row?.present && (
        <MLH1SuccessCallout mlh1={mlh1Row} totalGenes={diagnostic!.n_genes} />
      )}

      {effectiveData && sortedRows && (
        <div className="mt-5">
          {target !== "none" && (
            <div className="flex items-center text-sm text-ink">
              <OverlapSummary
                k={effectiveData.overlap_count}
                mlh1={dataset === "coadread" && target === "tmb" ? mlh1Row : undefined}
                totalGenes={diagnostic?.n_genes}
                n={sortedRows.length}
                setName={ref}
                matchedSymbols={matchedSymbols}
              />
              <InfoTip text={TIPS.overlap} label="About the overlap count" />
            </div>
          )}
          <div className="mt-3 flex items-center text-xs tracking-wide text-muted">
            Revealed genes
            <InfoTip
              text={TIPS.revealedGenes}
              label="About the revealed genes"
            />
          </div>
          {/* Single-gene rank column is populated only for (HNSC,HPV)
              and (coadread,TMB) — the (dataset, target) pairs with a
              single-gene diagnostic. Computed server-side via /evaluate
              (airgap-clean: reveal already happens there). */}
          {(() => {
            const showRank = sortedRows.some((r) => r.rank != null);
            return (
              <div className="mt-2 overflow-hidden rounded-md border border-border">
                <table className="w-full text-sm">
                  <thead className="bg-card text-xs tracking-wide text-muted">
                    <tr>
                      <th className="px-3 py-2 text-left">Gene code</th>
                      <th className="px-3 py-2 text-left">Symbol</th>
                      {target !== "none" && (
                        <th className="px-3 py-2 text-left">Matched</th>
                      )}
                      {showRank && (
                        <th className="px-3 py-2 text-right">
                          <span className="inline-flex items-center">
                            Single-gene rank
                            <InfoTip
                              text={TIPS.singleGeneRank}
                              label="About the single-gene rank"
                            />
                          </span>
                        </th>
                      )}
                    </tr>
                  </thead>
                  <tbody>
                    {sortedRows.map((row) => (
                      <tr
                        key={row.id}
                        style={
                          target !== "none" && row.matched
                            ? { backgroundColor: "#FBF1E6" }
                            : undefined
                        }
                        className="text-ink"
                      >
                        <td className="px-3 py-2 font-mono">{row.id}</td>
                        <td
                          className={
                            "px-3 py-2 " +
                            (target !== "none" && row.matched ? "font-semibold" : "")
                          }
                          style={
                            target !== "none" && row.matched
                              ? { color: "#BC6B2E" }
                              : undefined
                          }
                        >
                          {row.symbol}
                        </td>
                        {target !== "none" && (
                          <td className="px-3 py-2">
                            {row.matched ? (
                              <span
                                aria-label="matches reference set"
                                style={{ color: "#BC6B2E", fontWeight: 600 }}
                              >
                                ✓
                              </span>
                            ) : (
                              <span aria-label="no match" className="text-muted">
                                —
                              </span>
                            )}
                          </td>
                        )}
                        {showRank && (
                          <td className="px-3 py-2 text-right font-mono">
                            {row.rank != null && row.total != null ? (
                              <>
                                <span className="text-ink">{row.rank}</span>
                                <span className="text-muted"> / {row.total.toLocaleString()}</span>
                                {row.single_gene_metric != null && (
                                  <span className="ml-2 text-[11px] text-muted">
                                    {row.metric_kind === "auroc"
                                      ? `AUROC ${fmtFit(row.single_gene_metric, 3)}`
                                      : `ρ ${fmtFit(row.single_gene_metric, 3)}`}
                                  </span>
                                )}
                              </>
                            ) : (
                              <span className="text-muted">—</span>
                            )}
                          </td>
                        )}
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            );
          })()}
        </div>
      )}

      {dataset === "coadread" && target === "tmb" && (
        <TMBRankPanel
          data={diagnostic}
          loading={diagLoading}
          error={diagError}
        />
      )}
      {dataset === "hnsc" && target === "hpv" && (
        <HPVRankPanel
          data={hpvDiag}
          loading={hpvDiagLoading}
          error={hpvDiagError}
        />
      )}
    </SectionCard>
  );
}

// Accent SUCCESS callout for the TMB objective: MLH1's rank on the TMB
// negative-association axis. Surfaces what's already in the diagnostic
// data, so the "0 of N winning genes in MMR" line stops reading as a
// flat failure.
function MLH1SuccessCallout({
  mlh1,
  totalGenes,
}: {
  mlh1: GeneRankRow;
  totalGenes: number;
}) {
  if (!mlh1.present || mlh1.rank == null) return null;
  const pct = Math.round((mlh1.rank / totalGenes) * 1000) / 10;
  return (
    <div
      className="mt-4 flex items-start gap-3 rounded-md border border-accent/40 bg-accent/5 px-4 py-3"
      role="status"
    >
      <span
        aria-hidden
        className="mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full bg-accent text-card"
        style={{ fontSize: 12 }}
      >
        ✓
      </span>
      <p className="text-sm text-accent">
        Blind, <span className="font-semibold">MLH1</span> ranks{" "}
        <span className="font-mono">
          {mlh1.rank} / {totalGenes.toLocaleString()}
        </span>{" "}
        (top {pct}%) on the TMB objective — the causal gene surfaced near the
        top without ever seeing gene names.
      </p>
    </div>
  );
}

// TMB-rank diagnostic: where each MMR / IMMUNE gene lands when every
// gene is ranked by signed Spearman with TMB on the engine's TMB cohort.
// "rank near 1 = findable; high rank = the signal isn't in the
// expression data." Data is fetched by the parent Evaluator and passed
// in so the success callout and OverlapSummary share the same response.
function TMBRankPanel({
  data,
  loading,
  error,
}: {
  data: TMBRankDiagnostic | null;
  loading: boolean;
  error: string | null;
}) {
  return (
    <div className="mt-6 border-t border-border pt-5">
      <div className="flex items-center text-xs tracking-wide text-muted">
        Reference-gene diagnostic
        <InfoTip text={TIPS.tmbRank} label="About the TMB-rank diagnostic" />
      </div>
      <p className="mt-1 text-[11.5px] italic text-muted">
        Where each known gene sits on the engine's TMB target — rank near 1 = findable;
        high rank = the signal isn't in the expression data.
      </p>

      {loading && (
        <p className="mt-3 text-xs text-muted">computing ranks…</p>
      )}
      {error && (
        <p className="mt-3 rounded-md border border-highlight/40 bg-highlight/10 px-3 py-2 text-xs text-highlight">
          {error}
        </p>
      )}

      {data && (
        <div className="mt-3 space-y-4">
          <p className="text-[11px] text-muted">
            <span className="font-mono text-ink">{data.n_samples}</span> samples ·
            <span className="font-mono text-ink"> {data.n_genes.toLocaleString()}</span> genes
            ranked.
          </p>

          <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
            <RankList
              title="MMR (DNA spell-checkers)"
              subtitle="Expected near rank 1 if findable."
              rows={data.mmr}
              total={data.n_genes}
            />
            <RankList
              title="Immune"
              subtitle="Expected near the bottom — they rise with TMB. Informative, not a failure."
              rows={data.immune}
              total={data.n_genes}
            />
          </div>

          <div>
            <div className="text-[11px] font-semibold tracking-wide text-muted">
              Top {data.top_negative.length} most-negatively-correlated genes
            </div>
            <p className="mt-1 text-[11px] text-muted">
              What outranks MLH1 — every gene above MLH1's rank on the TMB axis.
            </p>
            <div className="mt-2 overflow-hidden rounded-md border border-border">
              <table className="w-full text-xs">
                <thead className="bg-card text-[10px] tracking-wide text-muted">
                  <tr>
                    <th className="px-3 py-1.5 text-left">Symbol</th>
                    <th className="px-3 py-1.5 text-right">Correlation</th>
                    <th className="px-3 py-1.5 text-right">Rank / N</th>
                  </tr>
                </thead>
                <tbody>
                  {data.top_negative.map((r) => (
                    <tr key={r.symbol} className="border-t border-border">
                      <td className="px-3 py-1.5 font-mono text-ink">{r.symbol}</td>
                      <td className="px-3 py-1.5 text-right font-mono text-ink">
                        {r.corr == null ? "—" : r.corr.toFixed(4)}
                      </td>
                      <td className="px-3 py-1.5 text-right font-mono text-muted">
                        {r.rank == null ? "—" : `${r.rank} / ${data.n_genes}`}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// HNSC-side mirror of <TMBRankPanel>. Reads /diagnostic/hpv-rank: per-
// gene single-gene HPV+/HPV− AUROC on the engine's TRAIN split. Shows
// CDKN2A (p16), each cell-cycle / E2F-target gene, and the top single-
// gene separators. Caption matches the prompt verbatim.
function HPVRankPanel({
  data,
  loading,
  error,
}: {
  data: HPVRankDiagnostic | null;
  loading: boolean;
  error: string | null;
}) {
  return (
    <div className="mt-6 border-t border-border pt-5">
      <div className="flex items-center text-xs tracking-wide text-muted">
        Reference-gene diagnostic
        <InfoTip text={TIPS.hpvRank} label="About the HPV-rank diagnostic" />
      </div>
      <p className="mt-1 text-[11.5px] italic text-muted">
        Where each known HPV marker sits as a single-gene HPV+/HPV−
        separator — rank near 1 = recoverable; high rank = out-competed.
      </p>

      {loading && (
        <p className="mt-3 text-xs text-muted">computing ranks…</p>
      )}
      {error && (
        <p className="mt-3 rounded-md border border-highlight/40 bg-highlight/10 px-3 py-2 text-xs text-highlight">
          {error}
        </p>
      )}

      {data && (
        <div className="mt-3 space-y-4">
          <p className="text-[11px] text-muted">
            <span className="font-mono text-ink">{data.n_samples}</span>{" "}
            train samples (HPV+ <span className="font-mono text-ink">{data.n_pos}</span>{" "}
            / HPV− <span className="font-mono text-ink">{data.n_neg}</span>) ·
            <span className="font-mono text-ink"> {data.n_genes.toLocaleString()}</span> genes
            ranked.
          </p>

          <div className="grid grid-cols-1 gap-3 md:grid-cols-2">
            <RankList
              title="p16 (CDKN2A)"
              subtitle="The canonical HPV+ surrogate marker."
              rows={data.p16}
              total={data.n_genes}
            />
            <RankList
              title="Cell-cycle / E2F targets"
              subtitle="HPV-E7 wrecks RB and releases this program — they go ↑ in HPV+."
              rows={data.cell_cycle}
              total={data.n_genes}
            />
          </div>

          <div>
            <div className="text-[11px] font-semibold tracking-wide text-muted">
              Top {data.top_separators.length} single-gene HPV+/HPV− separators
            </div>
            <p className="mt-1 text-[11px] text-muted">
              What out-competes CDKN2A as a single feature on this cohort.
            </p>
            <div className="mt-2 overflow-hidden rounded-md border border-border">
              <table className="w-full text-xs">
                <thead className="bg-card text-[10px] tracking-wide text-muted">
                  <tr>
                    <th className="px-3 py-1.5 text-left">Symbol</th>
                    <th className="px-3 py-1.5 text-right">AUROC</th>
                    <th className="px-3 py-1.5 text-right">Rank / N</th>
                  </tr>
                </thead>
                <tbody>
                  {data.top_separators.map((r) => (
                    <tr key={r.symbol} className="border-t border-border">
                      <td className="px-3 py-1.5 font-mono text-ink">{r.symbol}</td>
                      <td className="px-3 py-1.5 text-right font-mono text-ink">
                        {r.corr == null ? "—" : r.corr.toFixed(4)}
                      </td>
                      <td className="px-3 py-1.5 text-right font-mono text-muted">
                        {r.rank == null ? "—" : `${r.rank} / ${data.n_genes}`}
                      </td>
                    </tr>
                  ))}
                </tbody>
              </table>
            </div>
          </div>
        </div>
      )}
    </div>
  );
}

// Synergy = how much the group's combined held-out beats its best
// single gene's metric. Captures the additive lift the GROUP gives
// beyond its strongest constituent — high synergy = a real group
// effect, low / negative = a "best gene + passengers" pattern.
// Lifted to module scope so both the merged Groups table and the
// landscape scatter share one source of truth.
function synergyOf(m: RankedModule): number | null {
  if (m.combined_holdout == null || !Number.isFinite(m.combined_holdout)) {
    return null;
  }
  let best: number | null = null;
  for (const g of m.per_gene) {
    const v = g.single_gene_metric;
    if (v == null || !Number.isFinite(v)) continue;
    if (best == null || v > best) best = v;
  }
  if (best == null) return null;
  return m.combined_holdout - best;
}

// Category buckets a module by its ref_sets membership. Single-marker
// sets (p16 / immune) win over broader proliferation sets
// (cell_cycle / MMR) so a module with both gets the more specific
// colour. "other" = the faint grey background dot.
type ModuleCategory = "winner" | "p16" | "immune" | "cell_cycle" | "MMR" | "other";

function categoriseModule(
  m: RankedModule, winnerSet: Set<string>,
): ModuleCategory {
  // Winner takes priority over reference-set membership so the
  // ringed dot is visible even if the winner happens to overlap
  // a known set.
  if (m.gene_ids.length === winnerSet.size) {
    let ok = true;
    for (const g of m.gene_ids) if (!winnerSet.has(g)) { ok = false; break; }
    if (ok) return "winner";
  }
  if (m.ref_sets.includes("p16")) return "p16";
  if (m.ref_sets.includes("immune")) return "immune";
  if (m.ref_sets.includes("cell_cycle")) return "cell_cycle";
  if (m.ref_sets.includes("MMR")) return "MMR";
  return "other";
}

// Canvas-based landscape scatter — ~6k dots, so SVG is too heavy.
// Renders the dots on <canvas> and the chrome (axes, gridlines,
// quadrant guides, labels, legend) as an absolutely-positioned SVG
// overlay sharing the same plot rect.
function FitnessSynergyScatter({
  modules,
  winnerGeneIds,
  dataset,
}: {
  modules: RankedModule[];
  winnerGeneIds: string[];
  dataset: DatasetId;
}) {
  const wrapperRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);
  const [width, setWidth] = useState<number>(720);
  const HEIGHT = 320;
  const PAD = useMemo(
    () => ({ top: 28, right: 16, bottom: 44, left: 56 }),
    [],
  );

  // Hit-test record: screen positions of HIGHLIGHTED dots only
  // (winner + ref-set members). Populated when the canvas paints
  // and consulted by the mousemove handler. The ~6k background dots
  // are deliberately not in here — they're never revealed and would
  // be a waste to hit-test.
  type Hit = { cx: number; cy: number; cat: ModuleCategory; m: RankedModule };
  const hitsRef = useRef<Hit[]>([]);
  const [hover, setHover] = useState<{
    cat: ModuleCategory; m: RankedModule; cx: number; cy: number;
  } | null>(null);
  // "Hide failed-site groups" toggle: when on, highlighted dots whose
  // group did NOT survive the oropharynx-subgroup check are dropped
  // from the plot AND from the hit-test. Background dots are
  // unaffected (they're not site-tested anyway).
  const [hideFailedSite, setHideFailedSite] = useState<boolean>(false);

  // Track the wrapper width so the canvas + overlay stay sized to
  // the column the panel renders into.
  useEffect(() => {
    const el = wrapperRef.current;
    if (!el) return;
    function update() {
      if (!el) return;
      setWidth(Math.max(320, el.clientWidth));
    }
    update();
    const ro = new ResizeObserver(update);
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // Project + categorise once per `modules`.
  const winnerSet = useMemo<Set<string>>(
    () => new Set(winnerGeneIds), [winnerGeneIds],
  );
  const points = useMemo(() => {
    type P = {
      x: number;
      y: number;
      cat: ModuleCategory;
      m: RankedModule;
    };
    const out: P[] = [];
    for (const m of modules) {
      const x = m.gp_fitness;
      const y = synergyOf(m);
      if (x == null || !Number.isFinite(x)) continue;
      if (y == null || !Number.isFinite(y)) continue;
      out.push({ x, y, cat: categoriseModule(m, winnerSet), m });
    }
    return out;
  }, [modules, winnerSet]);

  // Bounded reveal: highlighted groups only (winner + ref-set members).
  // A few dozen modules × a few genes ≈ ~100-200 ids — fine for a
  // single batched /reveal. Background dots are deliberately left
  // opaque; revealing all of them would defeat the airgap.
  const highlightedGeneIds = useMemo<string[]>(() => {
    const ids = new Set<string>();
    for (const p of points) {
      if (p.cat === "other") continue;
      for (const g of p.m.gene_ids) ids.add(g);
    }
    return Array.from(ids);
  }, [points]);
  const [highlightSymbols, setHighlightSymbols] = useState<Record<string, string>>(
    {},
  );
  useEffect(() => {
    if (highlightedGeneIds.length === 0) return;
    const needed = highlightedGeneIds.filter((g) => !(g in highlightSymbols));
    if (needed.length === 0) return;
    let cancelled = false;
    postReveal(needed)
      .then((r) => {
        if (cancelled) return;
        const additions: Record<string, string> = {};
        needed.forEach((gid, i) => {
          additions[gid] = r.symbols[i] ?? "";
        });
        setHighlightSymbols((prev) => ({ ...prev, ...additions }));
      })
      .catch(() => {
        // Non-fatal — tooltips fall back to opaque IDs.
      });
    return () => {
      cancelled = true;
    };
    // Key on the joined opaque-id list so re-renders without a real
    // change in highlighted modules don't re-fetch.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [highlightedGeneIds.join("·")]);

  const stats = useMemo(() => {
    if (points.length === 0) {
      return null;
    }
    let xMin = Infinity, xMax = -Infinity;
    let yMin = Infinity, yMax = -Infinity;
    const xs: number[] = [];
    for (const p of points) {
      if (p.x < xMin) xMin = p.x;
      if (p.x > xMax) xMax = p.x;
      if (p.y < yMin) yMin = p.y;
      if (p.y > yMax) yMax = p.y;
      xs.push(p.x);
    }
    // Pad ranges a bit; force y range to span 0 so the synergy=0
    // guide line is always visible.
    const xPad = Math.max(0.005, (xMax - xMin) * 0.05);
    const yPad = Math.max(0.005, (yMax - yMin) * 0.08);
    const x0 = xMin - xPad;
    const x1 = xMax + xPad;
    const y0 = Math.min(yMin - yPad, 0);
    const y1 = Math.max(yMax + yPad, 0);
    // Median GP fitness for the vertical quadrant guide.
    const sorted = xs.slice().sort((a, b) => a - b);
    const mid = sorted.length === 0
      ? 0
      : sorted.length % 2 === 1
        ? sorted[(sorted.length - 1) >> 1]
        : 0.5 * (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]);
    return { x0, x1, y0, y1, medianX: mid };
  }, [points]);

  const plotW = Math.max(1, width - PAD.left - PAD.right);
  const plotH = Math.max(1, HEIGHT - PAD.top - PAD.bottom);

  function project(x: number, y: number, s: NonNullable<typeof stats>) {
    const px =
      PAD.left + ((x - s.x0) / (s.x1 - s.x0)) * plotW;
    const py =
      PAD.top + plotH - ((y - s.y0) / (s.y1 - s.y0)) * plotH;
    return [px, py] as const;
  }

  // Paint the canvas whenever data / dimensions change.
  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas || !stats) return;
    const s = stats;   // narrowed for the closure
    const dpr = (typeof window !== "undefined" && window.devicePixelRatio) || 1;
    canvas.width = Math.round(width * dpr);
    canvas.height = Math.round(HEIGHT * dpr);
    canvas.style.width = `${width}px`;
    canvas.style.height = `${HEIGHT}px`;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    ctx.clearRect(0, 0, width, HEIGHT);

    // Faint background dots first (the "other" group), then the
    // highlighted ones on top, then the winner last (drawn as a
    // ring) so nothing occludes it.
    const layers: Record<ModuleCategory, { x: number; y: number; m: RankedModule }[]> = {
      other: [],
      cell_cycle: [],
      MMR: [],
      p16: [],
      immune: [],
      winner: [],
    };
    for (const p of points) layers[p.cat].push({ x: p.x, y: p.y, m: p.m });

    // Reset the hit-test array on every repaint; the dots' screen
    // positions move with the canvas size.
    const hits: Hit[] = [];

    // Background layer: faint grey dots, no site encoding (the data
    // doesn't apply — "other" modules aren't reference-tagged anyway).
    function paintBackground(
      pts: { x: number; y: number; m: RankedModule }[],
      fill: string,
      radius: number,
      alpha: number,
    ) {
      ctx!.save();
      ctx!.globalAlpha = alpha;
      ctx!.fillStyle = fill;
      for (const pt of pts) {
        const [px, py] = project(pt.x, pt.y, s);
        ctx!.beginPath();
        ctx!.arc(px, py, radius, 0, Math.PI * 2);
        ctx!.fill();
      }
      ctx!.restore();
    }

    // Highlighted layer: encode site-survival on the FILL. Solid =
    // survives the oropharynx check (or n too small to test); hollow
    // (white fill + coloured stroke) = failed it — signal is at least
    // partly location. Hide failed dots when the user has flipped the
    // toggle; they're also removed from the hit-test array.
    function paintHighlighted(
      pts: { x: number; y: number; m: RankedModule }[],
      cat: ModuleCategory,
      fill: string,
      stroke: string,
      radius: number,
      alpha: number,
    ) {
      ctx!.save();
      ctx!.globalAlpha = alpha;
      for (const pt of pts) {
        const failsSite = pt.m.survives_site === false;
        if (hideFailedSite && failsSite) continue;
        const [px, py] = project(pt.x, pt.y, s);
        ctx!.beginPath();
        ctx!.arc(px, py, radius, 0, Math.PI * 2);
        ctx!.fillStyle = failsSite ? "#FFFFFF" : fill;
        ctx!.fill();
        ctx!.strokeStyle = stroke;
        ctx!.lineWidth = failsSite ? 1.6 : 1.2;
        ctx!.stroke();
        hits.push({ cx: px, cy: py, cat, m: pt.m });
      }
      ctx!.restore();
    }

    const palP16 = RANK_COLORS.p16;
    const palImm = RANK_COLORS.immune;
    const palCC = RANK_COLORS.cell_cycle;
    const palMMR = RANK_COLORS.MMR;
    const palWin = RANK_COLORS.winner;

    paintBackground(layers.other, "#A8AEB5", 1.4, 0.18);
    paintHighlighted(layers.cell_cycle, "cell_cycle", palCC.fill, palCC.stroke, 3.4, 0.85);
    paintHighlighted(layers.MMR,        "MMR",        palMMR.fill, palMMR.stroke, 3.4, 0.85);
    paintHighlighted(layers.p16,        "p16",        palP16.fill, palP16.stroke, 4.0, 0.95);
    paintHighlighted(layers.immune,     "immune",     palImm.fill, palImm.stroke, 4.0, 0.95);
    // Winner — a teal ring at radius 7.5 with a coloured centre at
    // radius 4.5. If the winner's group failed the site check (rare
    // — the engine usually picks survives_site=true), hollow the
    // centre too (white fill) so the encoding is consistent.
    for (const pt of layers.winner) {
      const failsSite = pt.m.survives_site === false;
      if (hideFailedSite && failsSite) continue;
      const [px, py] = project(pt.x, pt.y, s);
      ctx.save();
      ctx.fillStyle = failsSite ? "#FFFFFF" : palWin.fill;
      ctx.beginPath();
      ctx.arc(px, py, 4.5, 0, Math.PI * 2);
      ctx.fill();
      ctx.lineWidth = 2;
      ctx.strokeStyle = palWin.stroke;
      ctx.beginPath();
      ctx.arc(px, py, 4.5, 0, Math.PI * 2);
      ctx.stroke();
      ctx.beginPath();
      ctx.arc(px, py, 7.5, 0, Math.PI * 2);
      ctx.stroke();
      ctx.restore();
      hits.push({ cx: px, cy: py, cat: "winner", m: pt.m });
    }
    hitsRef.current = hits;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [points, stats, width, plotW, plotH, hideFailedSite]);

  if (!stats || points.length === 0) {
    return null;
  }

  // Build axis ticks: 4 evenly-spaced ticks on each axis, formatted
  // to 2 decimals.
  function ticks(a: number, b: number, n: number): number[] {
    const out: number[] = [];
    for (let i = 0; i <= n; i++) out.push(a + (i / n) * (b - a));
    return out;
  }
  const xTicks = ticks(stats.x0, stats.x1, 4);
  const yTicks = ticks(stats.y0, stats.y1, 4);
  const medianXpx = project(stats.medianX, 0, stats)[0];
  const zeroYpx = project(0, 0, stats)[1];

  // Quadrant captions — placed inside each quadrant near a corner.
  const captions = [
    {
      x: PAD.left + 6,
      y: PAD.top + 14,
      text: "real teamwork",
      sub: "(unexplored by the engine)",
      anchor: "start" as const,
    },
    {
      x: PAD.left + plotW - 6,
      y: PAD.top + 14,
      text: "ideal — usually empty",
      sub: "(engine + teamwork)",
      anchor: "end" as const,
    },
    {
      x: PAD.left + 6,
      y: PAD.top + plotH - 8,
      text: "weak / junk",
      sub: "",
      anchor: "start" as const,
    },
    {
      x: PAD.left + plotW - 6,
      y: PAD.top + plotH - 8,
      text: "engine's lone-gene detectors",
      sub: "",
      anchor: "end" as const,
    },
  ];

  // Per-dataset legend entries (single-marker first, broader second).
  const legend: { label: string; pal: RankPalette; ring?: boolean }[] = [
    { label: "all explored groups", pal: { stroke: "#7B838C", fill: "#A8AEB5", bg: "#FFFFFF" } },
    { label: "winner & picks", pal: RANK_COLORS.winner, ring: true },
  ];
  if (dataset === "hnsc") {
    legend.push({ label: "p16 (CDKN2A)", pal: RANK_COLORS.p16 });
    legend.push({ label: "cell_cycle", pal: RANK_COLORS.cell_cycle });
  } else {
    legend.push({ label: "immune", pal: RANK_COLORS.immune });
    legend.push({ label: "MMR", pal: RANK_COLORS.MMR });
  }

  return (
    <div className="mt-4">
      <div className="flex flex-wrap items-center justify-between gap-2">
        <div className="flex items-center text-[11px] tracking-wide text-muted">
          Every group the engine explored
          <InfoTip
            text={
              "Each dot is a group of genes the engine tried. Left–" +
              "right = how much the engine liked it; up–down = how " +
              "much the group beats its best single gene. Highlighted " +
              "dots show where the known HPV genes fall. Fill also " +
              "encodes the site check: solid = the group&rsquo;s signal " +
              "holds in oropharynx patients too; hollow = it dropped " +
              "there, so part of the signal is location."
            }
            label="About the fitness × synergy scatter"
          />
        </div>
        <label className="flex items-center gap-1.5 text-[11px] text-muted">
          <input
            type="checkbox"
            checked={hideFailedSite}
            onChange={(e) => setHideFailedSite(e.target.checked)}
            className="h-3.5 w-3.5 cursor-pointer accent-accent"
          />
          Hide groups that fail the site check
        </label>
      </div>
      <p className="mt-0.5 text-[11px] italic text-muted">
        Every group the engine explored. Left–right = how much the
        engine liked it; up–down = how much the group beats its best
        single gene. Highlighted dots show where the known HPV genes
        fall.
        <span className="ml-1">
          <span className="font-semibold not-italic text-ink">Solid</span>{" "}
          dots survive the site check;{" "}
          <span className="font-semibold not-italic text-ink">hollow</span>{" "}
          dots fail it (their signal is partly location).
        </span>
      </p>

      <div
        ref={wrapperRef}
        className="relative mt-2 rounded-md border border-border bg-card"
        style={{ width: "100%", height: HEIGHT }}
        onMouseMove={(e) => {
          const el = wrapperRef.current;
          if (!el) return;
          const rect = el.getBoundingClientRect();
          const cx = e.clientX - rect.left;
          const cy = e.clientY - rect.top;
          let nearest: Hit | null = null;
          let best = 8 * 8;   // pick within ~8px
          for (const h of hitsRef.current) {
            const dx = h.cx - cx;
            const dy = h.cy - cy;
            const d2 = dx * dx + dy * dy;
            if (d2 < best) {
              best = d2;
              nearest = h;
            }
          }
          if (nearest) {
            setHover({
              cat: nearest.cat,
              m: nearest.m,
              cx: nearest.cx,
              cy: nearest.cy,
            });
          } else if (hover) {
            setHover(null);
          }
        }}
        onMouseLeave={() => setHover(null)}
      >
        <canvas
          ref={canvasRef}
          aria-label="Genetic-programming fitness by synergy scatter"
          style={{ display: "block" }}
        />
        <svg
          width={width}
          height={HEIGHT}
          style={{
            position: "absolute",
            inset: 0,
            pointerEvents: "none",
          }}
        >
          {/* Plot frame */}
          <rect
            x={PAD.left}
            y={PAD.top}
            width={plotW}
            height={plotH}
            fill="none"
            stroke="#ECEAE4"
          />

          {/* Y gridlines + tick labels */}
          {yTicks.map((t, i) => {
            const [, py] = project(stats.x0, t, stats);
            return (
              <g key={`yt-${i}`}>
                <line
                  x1={PAD.left}
                  y1={py}
                  x2={PAD.left + plotW}
                  y2={py}
                  stroke="#F1EFEA"
                />
                <text
                  x={PAD.left - 6}
                  y={py + 3}
                  fontSize={10}
                  fill="#6E7F8C"
                  textAnchor="end"
                >
                  {t.toFixed(2)}
                </text>
              </g>
            );
          })}
          {/* X gridlines + tick labels */}
          {xTicks.map((t, i) => {
            const [px] = project(t, stats.y0, stats);
            return (
              <g key={`xt-${i}`}>
                <line
                  x1={px}
                  y1={PAD.top}
                  x2={px}
                  y2={PAD.top + plotH}
                  stroke="#F1EFEA"
                />
                <text
                  x={px}
                  y={PAD.top + plotH + 14}
                  fontSize={10}
                  fill="#6E7F8C"
                  textAnchor="middle"
                >
                  {t.toFixed(2)}
                </text>
              </g>
            );
          })}

          {/* Quadrant guides: median GP fitness (vertical) + synergy=0
              (horizontal). */}
          <line
            x1={medianXpx}
            y1={PAD.top}
            x2={medianXpx}
            y2={PAD.top + plotH}
            stroke="#BC6B2E"
            strokeWidth={1}
            strokeDasharray="4 4"
            strokeOpacity={0.55}
          />
          <line
            x1={PAD.left}
            y1={zeroYpx}
            x2={PAD.left + plotW}
            y2={zeroYpx}
            stroke="#BC6B2E"
            strokeWidth={1}
            strokeDasharray="4 4"
            strokeOpacity={0.55}
          />
          <text
            x={medianXpx + 3}
            y={PAD.top - 4}
            fontSize={9}
            fill="#A75D2A"
            textAnchor="start"
          >
            median genetic-programming fitness
          </text>
          <text
            x={PAD.left + plotW - 4}
            y={zeroYpx - 3}
            fontSize={9}
            fill="#A75D2A"
            textAnchor="end"
          >
            synergy = 0
          </text>

          {/* Quadrant captions */}
          {captions.map((c, i) => (
            <g key={`cap-${i}`}>
              <text
                x={c.x}
                y={c.y}
                fontSize={10}
                fontWeight={600}
                fill="#46545E"
                textAnchor={c.anchor}
              >
                {c.text}
              </text>
              {c.sub && (
                <text
                  x={c.x}
                  y={c.y + 11}
                  fontSize={9}
                  fill="#9AA0A6"
                  textAnchor={c.anchor}
                >
                  {c.sub}
                </text>
              )}
            </g>
          ))}

          {/* Axis titles */}
          <text
            x={PAD.left + plotW / 2}
            y={HEIGHT - 6}
            fontSize={10.5}
            fontWeight={600}
            fill="#46545E"
            textAnchor="middle"
          >
            Genetic-programming fitness — what the engine preferred →
          </text>
          <text
            transform={`translate(14, ${PAD.top + plotH / 2}) rotate(-90)`}
            fontSize={10.5}
            fontWeight={600}
            fill="#46545E"
            textAnchor="middle"
          >
            Synergy — teamwork beyond best gene ↑
          </text>
        </svg>

        {hover && (
          <ScatterTooltip
            hover={hover}
            symbolByOpaque={highlightSymbols}
            plotW={plotW}
            plotH={plotH}
            pad={PAD}
            width={width}
            height={HEIGHT}
          />
        )}
      </div>

      {/* Legend */}
      <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[11px] text-muted">
        {legend.map((it) => (
          <span key={it.label} className="flex items-center gap-1.5">
            {it.ring ? (
              <span
                aria-hidden
                style={{
                  display: "inline-block",
                  width: 12,
                  height: 12,
                  borderRadius: 999,
                  border: `2px solid ${it.pal.stroke}`,
                  background: it.pal.fill,
                }}
              />
            ) : (
              <span
                aria-hidden
                style={{
                  display: "inline-block",
                  width: 9,
                  height: 9,
                  borderRadius: 999,
                  background: it.pal.fill,
                  border: `1.5px solid ${it.pal.stroke}`,
                }}
              />
            )}
            <span style={{ color: it.pal.stroke }}>{it.label}</span>
          </span>
        ))}
        <span className="flex items-center gap-1.5">
          <span
            aria-hidden
            style={{
              display: "inline-block",
              width: 9,
              height: 9,
              borderRadius: 999,
              background: "#FFFFFF",
              border: `1.5px solid ${RANK_COLORS.cell_cycle.stroke}`,
            }}
          />
          <span className="text-muted">hollow = fails site check</span>
        </span>
      </div>
    </div>
  );
}

// Hover tooltip for the fitness × synergy scatter. Renders next to
// the hovered HIGHLIGHTED dot — never for the faint background dots,
// since those aren't revealed. Clamps so it stays inside the chart
// rect even when the dot is near the edge.
function ScatterTooltip({
  hover,
  symbolByOpaque,
  plotW,
  plotH,
  pad,
  width,
  height,
}: {
  hover: {
    cat: ModuleCategory;
    m: RankedModule;
    cx: number;
    cy: number;
  };
  symbolByOpaque: Record<string, string>;
  plotW: number;
  plotH: number;
  pad: { top: number; right: number; bottom: number; left: number };
  width: number;
  height: number;
}) {
  const { m, cat, cx, cy } = hover;
  // Palette per category. "winner" uses the teal accent; ref-set
  // categories reuse the existing RANK_COLORS map; "other" should
  // not appear (we don't hit-test it).
  const pal =
    cat === "winner"
      ? RANK_COLORS.winner
      : cat === "p16"
        ? RANK_COLORS.p16
        : cat === "immune"
          ? RANK_COLORS.immune
          : cat === "cell_cycle"
            ? RANK_COLORS.cell_cycle
            : RANK_COLORS.MMR;
  const label =
    cat === "winner" ? "winner" : (cat as string);

  // Tooltip dimensions are approximate; we clamp the corner so the
  // box always stays inside the chart rect. Position 12px to the
  // right of the dot, biased up; flip if it would overflow.
  const TT_W = 220;
  const TT_H = 100;
  let left = cx + 14;
  let top = cy - 14 - TT_H;
  const leftMax = pad.left + plotW - TT_W - 4;
  if (left > leftMax) left = Math.max(pad.left + 4, cx - TT_W - 14);
  if (top < pad.top + 4) top = cy + 14;
  if (top + TT_H > pad.top + plotH + 4) top = Math.max(pad.top + 4, top - TT_H);
  // Final clamp to the wrapper bounds.
  left = Math.min(Math.max(left, 4), width - TT_W - 4);
  top = Math.min(Math.max(top, 4), height - TT_H - 4);

  const syn = synergyOf(m);
  const symbols = m.gene_ids.map((g) => {
    const s = symbolByOpaque[g];
    return s && s.length > 0 ? s : g;
  });
  const allRevealed = m.gene_ids.every(
    (g) => g in symbolByOpaque && symbolByOpaque[g].length > 0,
  );

  return (
    <div
      role="tooltip"
      style={{
        position: "absolute",
        left,
        top,
        width: TT_W,
        pointerEvents: "none",
        zIndex: 5,
      }}
      className="rounded-md border bg-card px-2.5 py-2 text-[11px] shadow-md"
    >
      <div className="flex items-center justify-between gap-2">
        <span
          className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
          style={{
            color: pal.stroke,
            backgroundColor: pal.bg,
            border: `1px solid ${pal.stroke}`,
          }}
        >
          {label}
        </span>
        <span className="font-mono text-muted">{m.size} gene{m.size === 1 ? "" : "s"}</span>
      </div>
      <div className="mt-1 break-words font-mono text-ink">
        {allRevealed ? symbols.join(", ") : "(revealing…)"}
      </div>
      <dl className="mt-1.5 grid grid-cols-2 gap-x-2 gap-y-0.5 text-[10.5px] text-muted">
        <dt>Genetic-programming fitness</dt>
        <dd className="text-right font-mono text-ink">
          {fmtFit(m.gp_fitness ?? null, 3)}
        </dd>
        <dt>Combined AUROC</dt>
        <dd className="text-right font-mono text-ink">
          {fmtFit(m.combined_holdout, 3)}
        </dd>
        <dt>Coherence</dt>
        <dd className="text-right font-mono text-ink">
          {fmtFit(m.coherence, 3)}
        </dd>
        <dt>Synergy</dt>
        <dd className="text-right font-mono text-ink">
          {fmtFit(syn, 3)}
        </dd>
      </dl>
    </div>
  );
}

// Ranked-modules panel: shown for coherence-on supervised runs. Each
// row is a coordinated gene module the engine explored, scored by
// combined held-out AUROC on the SAME train/test split the run used.
// Symbols are auto-revealed for the visible page in one batched
// /reveal call — bounded by page size, never the whole map.
const MODULE_PAGE_SIZE = 25;

function ModuleRankingPanel({
  runId,
  dataset,
  target,
  winnerGeneIds,
}: {
  runId: string;
  dataset: DatasetId;
  target: Target;
  winnerGeneIds: string[];
}) {
  const [data, setData] = useState<ModuleRanking | null>(null);
  const [loading, setLoading] = useState<boolean>(false);
  const [error, setError] = useState<string | null>(null);
  type SortKey = "gp_fitness" | "combined" | "coherence" | "synergy";
  type SortDir = "asc" | "desc";
  // Default sort: the engine's own preference order (what the search
  // actually picked). The other three are after-the-fact re-score
  // lenses.
  const [sortKey, setSortKey] = useState<SortKey>("gp_fitness");
  const [sortDir, setSortDir] = useState<SortDir>("desc");
  const [page, setPage] = useState<number>(0);
  // Symbol cache keyed by opaque ID — accumulates across pages so
  // re-visiting a page is free.
  const [symbolByOpaque, setSymbolByOpaque] = useState<Record<string, string>>(
    {},
  );
  const [expandedIx, setExpandedIx] = useState<number | null>(null);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    setError(null);
    setData(null);
    setSymbolByOpaque({});
    setExpandedIx(null);
    setPage(0);
    getRunModules(runId)
      .then((d) => {
        if (!cancelled) setData(d);
      })
      .catch((e) => {
        if (!cancelled) setError(e instanceof Error ? e.message : String(e));
      })
      .finally(() => {
        if (!cancelled) setLoading(false);
      });
    return () => {
      cancelled = true;
    };
  }, [runId]);

  // Synergy / set categorisation are shared with the scatter; lift to
  // top-level helpers (defined just above this file's module-panel
  // section) so both views share one source of truth.

  const sortedModules = useMemo<RankedModule[]>(() => {
    if (!data) return [];
    const xs = [...data.modules];
    const valueOf = (m: RankedModule): number | null | undefined => {
      switch (sortKey) {
        case "coherence": return m.coherence;
        case "gp_fitness": return m.gp_fitness;
        case "synergy": return synergyOf(m);
        default: return m.combined_holdout;
      }
    };
    xs.sort((a, b) => {
      const va = valueOf(a), vb = valueOf(b);
      const fa = va == null || !Number.isFinite(va);
      const fb = vb == null || !Number.isFinite(vb);
      if (fa && fb) return 0;
      if (fa) return 1;
      if (fb) return -1;
      return sortDir === "asc"
        ? (va as number) - (vb as number)
        : (vb as number) - (va as number);
    });
    return xs;
    // synergyOf depends only on RankedModule's stable per-gene fields,
    // so we leave it out of the dep list.
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [data, sortKey, sortDir]);

  // Re-sort or re-load resets the page + collapses any open row so an
  // expanded "rank N" row doesn't strand once the underlying ranking
  // shifts.
  useEffect(() => {
    setPage(0);
    setExpandedIx(null);
  }, [sortKey, sortDir, data]);

  // Winner-set equality: unordered set of opaque IDs equal to the
  // winner's gene_ids. Used to badge that exact module — connects the
  // group view back to the Result cards.
  const winnerSet = useMemo<Set<string>>(() => new Set(winnerGeneIds), [
    winnerGeneIds,
  ]);
  function isWinnerSet(geneIds: string[]): boolean {
    if (geneIds.length !== winnerSet.size) return false;
    for (const g of geneIds) if (!winnerSet.has(g)) return false;
    return true;
  }

  const pageStart = page * MODULE_PAGE_SIZE;
  const pageEnd = Math.min(pageStart + MODULE_PAGE_SIZE, sortedModules.length);
  const pageModules = sortedModules.slice(pageStart, pageEnd);
  const nPages = Math.max(
    1,
    Math.ceil(sortedModules.length / MODULE_PAGE_SIZE),
  );

  // Auto-reveal the visible page's symbols in ONE batched /reveal —
  // bounded to ≤ MODULE_PAGE_SIZE × max_genes_per_set IDs. Skips
  // already-cached IDs.
  useEffect(() => {
    if (pageModules.length === 0) return;
    const needed = new Set<string>();
    for (const m of pageModules) {
      for (const g of m.gene_ids) {
        if (!(g in symbolByOpaque)) needed.add(g);
      }
    }
    if (needed.size === 0) return;
    const ids = Array.from(needed);
    let cancelled = false;
    postReveal(ids)
      .then((r) => {
        if (cancelled) return;
        const additions: Record<string, string> = {};
        ids.forEach((gid, i) => {
          additions[gid] = r.symbols[i] ?? "";
        });
        setSymbolByOpaque((prev) => ({ ...prev, ...additions }));
      })
      .catch((e) => {
        // Non-fatal — symbols just stay "(revealing…)" for this batch.
        console.error("module page reveal failed", e);
      });
    return () => {
      cancelled = true;
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [page, sortKey, data]);

  function toggle(localIx: number) {
    const globalIx = pageStart + localIx;
    setExpandedIx(globalIx === expandedIx ? null : globalIx);
  }

  return (
    <SectionCard
      title="Groups the engine explored"
      titleTip={TIPS.moduleRanking}
      subtitle={
        "Every gene group the engine tried, scored four ways so you can " +
        "judge whole groups, not just single genes. Genetic-programming " +
        "fitness: how much the engine itself liked the group. Combined " +
        "AUROC: how well the group separates the two kinds of tumour on " +
        "patients it never saw. Coherence: how tightly its genes move " +
        "together (a real module versus a random bag). Synergy: how much " +
        "the group beats its single best gene (real teamwork versus one " +
        "strong gene carrying passengers). The winning program is badged; " +
        "the Survives flags show whether a group still separates the " +
        "label under the confound checks."
      }
    >
      {loading && (
        <p className="text-xs text-muted">computing module ranks…</p>
      )}
      {error && (
        <p className="rounded-md border border-highlight/40 bg-highlight/10 px-3 py-2 text-xs text-highlight">
          {error}
        </p>
      )}

      {data && (
        <>
          <div className="flex items-center justify-between text-[11px] text-muted">
            <span>
              <span className="font-mono text-ink">{data.n_modules}</span>{" "}
              distinct modules · train{" "}
              <span className="font-mono text-ink">{data.n_train}</span>, test{" "}
              <span className="font-mono text-ink">{data.n_test}</span>
            </span>
            <div className="flex flex-wrap items-center gap-1">
              <span className="text-muted">sort:</span>
              {(
                [
                  ["gp_fitness", "Genetic-programming fitness"],
                  ["combined", "Combined AUROC"],
                  ["coherence", "Coherence"],
                  ["synergy", "Synergy"],
                ] as [SortKey, string][]
              ).map(([k, label]) => (
                <button
                  key={k}
                  type="button"
                  onClick={() => {
                    if (sortKey === k) setSortDir((d) => (d === "desc" ? "asc" : "desc"));
                    else { setSortKey(k); setSortDir("desc"); }
                  }}
                  aria-pressed={sortKey === k}
                  className={[
                    "rounded-md px-2 py-0.5 text-[11px] transition-colors",
                    sortKey === k
                      ? "bg-ink text-card"
                      : "border border-border bg-card text-ink hover:border-accent/40",
                  ].join(" ")}
                >
                  {label}{sortKey === k ? (sortDir === "desc" ? " ↓" : " ↑") : ""}
                </button>
              ))}
            </div>
          </div>

          {(data.subgroups?.site || data.subgroups?.purity) && (
            <div className="mt-2 flex items-center text-[11px] text-muted">
              <span>
                Survival flags shown for the held-out subgroups —
                {data.subgroups?.site && (
                  <>
                    {" site "}
                    <span className="font-mono text-ink">
                      ({data.subgroups.site.kind}, n={data.subgroups.site.n})
                    </span>
                  </>
                )}
                {data.subgroups?.site && data.subgroups?.purity && " · "}
                {data.subgroups?.purity && (
                  <>
                    {" purity "}
                    <span className="font-mono text-ink">
                      ({data.subgroups.purity.kind}, n={data.subgroups.purity.n})
                    </span>
                  </>
                )}
                .
              </span>
              <ParamHelp
                paramKey="module_survival"
                label="About the site / purity survival flags"
              />
            </div>
          )}

          <FitnessSynergyScatter
            modules={data.modules}
            winnerGeneIds={winnerGeneIds}
            dataset={dataset}
          />

          <div className="mt-3 overflow-hidden rounded-md border border-border">
            <table className="w-full text-sm">
              <thead className="bg-card text-xs tracking-wide text-muted">
                <tr>
                  <th className="px-3 py-2 text-left w-10">#</th>
                  <th className="px-3 py-2 text-right">
                    <span className="inline-flex items-center justify-end gap-1">
                      Genetic-programming fitness
                      <InfoTip
                        text="Higher means the engine liked this group more during the search. The very highest are its actual picks; low values are groups it barely looked at."
                        label="How to read Genetic-programming fitness"
                      />
                    </span>
                  </th>
                  <th className="px-3 py-2 text-right">
                    <span className="inline-flex items-center justify-end gap-1">
                      {data.metric_kind === "auroc"
                        ? "Combined AUROC"
                        : "Combined |ρ|"}
                      <InfoTip
                        text="How well the whole group separates the two kinds of tmour on patients it never saw. Around 0.5 is no better than chance; close to 1.0 is a clean split. For a continuous target this is a correlation instead, where further from 0 is stronger."
                        label="How to read Combined AUROC"
                      />
                    </span>
                  </th>
                  <th className="px-3 py-2 text-right">
                    <span className="inline-flex items-center justify-end gap-1">
                      Coherence
                      <InfoTip
                        text="Higher means the group's genes move together tightly, so it is a real co-expressed module. Low means the genes are largely unrelated, a random bag."
                        label="How to read Coherence"
                      />
                    </span>
                  </th>
                  <th className="px-3 py-2 text-right">
                    <span className="inline-flex items-center justify-end gap-1">
                      Synergy
                      <InfoTip
                        text="Positive and high means the group genuinely beats its best single gene, real teamwork. Around zero or negative means one strong gene is doing the work and the rest are passengers."
                        label="How to read Synergy"
                      />
                    </span>
                  </th>
                  <th className="px-3 py-2 text-right">Size</th>
                  <th className="px-3 py-2 text-left">Genes</th>
                  <th className="px-3 py-2 text-left">Tags</th>
                  {(data.subgroups?.site || data.subgroups?.purity) && (
                    <th className="px-3 py-2 text-left">
                      <span className="inline-flex items-center">
                        Survives
                        {/* Multi-paragraph copy doesn't fit a hover
                            tooltip — use the rich modal infrastructure
                            so it never clips. */}
                        <ParamHelp
                          paramKey="module_survival"
                          label="About the Survives column"
                        />
                      </span>
                    </th>
                  )}
                </tr>
              </thead>
              <tbody>
                {pageModules.map((m, localIx) => {
                  const globalIx = pageStart + localIx;
                  const open = expandedIx === globalIx;
                  // Tint by the most-specific matched set: single-marker
                  // sets (p16 / immune — deep gold) win over broader
                  // proliferation sets (cell_cycle / MMR — muted amber).
                  const primarySet =
                    m.ref_sets.find((s) => s === "p16" || s === "immune") ??
                    m.ref_sets.find(
                      (s) => s === "cell_cycle" || s === "MMR",
                    ) ??
                    m.ref_sets[0];
                  const rowBg = primarySet
                    ? colorForSet(primarySet).bg
                    : undefined;
                  const winner = isWinnerSet(m.gene_ids);
                  return (
                    <Fragment key={`${globalIx}-${m.gene_ids.join("·")}`}>
                      <tr
                        className="cursor-pointer border-t border-border text-ink hover:bg-bg"
                        style={rowBg ? { backgroundColor: rowBg } : undefined}
                        onClick={() => toggle(localIx)}
                        aria-expanded={open}
                      >
                        <td className="px-3 py-2 font-mono text-muted">
                          <span aria-hidden className="mr-1 text-[10px]">
                            {open ? "▼" : "▶"}
                          </span>
                          {globalIx + 1}
                        </td>
                        <td className="px-3 py-2 text-right font-mono">
                          {fmtFit(m.gp_fitness ?? null, 3)}
                        </td>
                        <td className="px-3 py-2 text-right font-mono">
                          {fmtFit(m.combined_holdout, 3)}
                        </td>
                        <td className="px-3 py-2 text-right font-mono">
                          {fmtFit(m.coherence, 3)}
                        </td>
                        <td className="px-3 py-2 text-right font-mono">
                          {fmtFit(synergyOf(m), 3)}
                        </td>
                        <td className="px-3 py-2 text-right font-mono">
                          {m.size}
                        </td>
                        <td className="px-3 py-2 font-mono text-[12px] leading-snug">
                          <ModuleSymbolList
                            geneIds={m.gene_ids}
                            symbolByOpaque={symbolByOpaque}
                            datasetRefSets={REFERENCE_SETS_BY_DATASET[dataset]}
                          />
                        </td>
                        <td className="px-3 py-2">
                          <div className="flex flex-wrap items-center gap-1.5">
                            {winner && (
                              <span
                                className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
                                style={{
                                  color: RANK_COLORS.winner.stroke,
                                  backgroundColor: RANK_COLORS.winner.bg,
                                  border: `1px solid ${RANK_COLORS.winner.stroke}`,
                                }}
                                title="This module's gene-set is exactly the winning program's gene-set."
                              >
                                winner
                              </span>
                            )}
                            {m.ref_sets.map((s) => {
                              const pal = colorForSet(s);
                              return (
                                <span
                                  key={s}
                                  className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
                                  style={{
                                    color: pal.stroke,
                                    backgroundColor: pal.bg,
                                    border: `1px solid ${pal.stroke}`,
                                  }}
                                  title={`Module contains a gene in the ${s} reference set`}
                                >
                                  {s}
                                </span>
                              );
                            })}
                          </div>
                        </td>
                        {(data.subgroups?.site || data.subgroups?.purity) && (
                          <td className="px-3 py-2">
                            <SurvivalChips module={m} subgroups={data.subgroups} />
                          </td>
                        )}
                      </tr>
                      {open && (
                        <tr
                          className="border-t border-border bg-bg"
                          style={rowBg ? { backgroundColor: rowBg } : undefined}
                        >
                          <td
                            colSpan={
                              data.subgroups?.site || data.subgroups?.purity
                                ? 9 : 8
                            }
                            className="px-3 py-3"
                          >
                            <ModuleGeneTable
                              module={m}
                              symbolByOpaque={symbolByOpaque}
                              datasetRefSets={REFERENCE_SETS_BY_DATASET[dataset]}
                              metricKind={data.metric_kind}
                            />
                            {m.best_program_repr && (
                              <ModuleBestProgram
                                programRepr={m.best_program_repr}
                                outputLabel={
                                  target === "msi"
                                    ? "MSI-H probability"
                                    : target === "hpv"
                                      ? "HPV+ probability"
                                      : target === "tmb"
                                        ? "TMB association"
                                        : "score"
                                }
                                symbolByOpaque={symbolByOpaque}
                              />
                            )}
                            {(data.subgroups?.site || data.subgroups?.purity) && (
                              <StratifiedAUROCStrip
                                module={m}
                                subgroups={data.subgroups}
                                metricKind={data.metric_kind}
                              />
                            )}
                          </td>
                        </tr>
                      )}
                    </Fragment>
                  );
                })}
              </tbody>
            </table>
          </div>

          <ModulePager
            page={page}
            nPages={nPages}
            pageStart={pageStart}
            pageEnd={pageEnd}
            total={sortedModules.length}
            onPage={setPage}
          />
        </>
      )}
    </SectionCard>
  );
}

function ModulePager({
  page,
  nPages,
  pageStart,
  pageEnd,
  total,
  onPage,
}: {
  page: number;
  nPages: number;
  pageStart: number;
  pageEnd: number;
  total: number;
  onPage: (p: number) => void;
}) {
  if (nPages <= 1) return null;
  return (
    <div className="mt-3 flex items-center justify-between text-[11px] text-muted">
      <span className="font-mono text-ink">
        {(pageStart + 1).toLocaleString()}–{pageEnd.toLocaleString()}
        <span className="text-muted"> of {total.toLocaleString()}</span>
      </span>
      <div className="flex items-center gap-1">
        <button
          type="button"
          onClick={() => onPage(0)}
          disabled={page === 0}
          className="rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-ink hover:border-accent/40 disabled:opacity-40"
          aria-label="First page"
        >
          |‹
        </button>
        <button
          type="button"
          onClick={() => onPage(Math.max(0, page - 1))}
          disabled={page === 0}
          className="rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-ink hover:border-accent/40 disabled:opacity-40"
          aria-label="Previous page"
        >
          ‹
        </button>
        <span className="font-mono text-ink">
          page {page + 1} / {nPages}
        </span>
        <button
          type="button"
          onClick={() => onPage(Math.min(nPages - 1, page + 1))}
          disabled={page >= nPages - 1}
          className="rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-ink hover:border-accent/40 disabled:opacity-40"
          aria-label="Next page"
        >
          ›
        </button>
        <button
          type="button"
          onClick={() => onPage(nPages - 1)}
          disabled={page >= nPages - 1}
          className="rounded-md border border-border bg-card px-2 py-0.5 text-[11px] text-ink hover:border-accent/40 disabled:opacity-40"
          aria-label="Last page"
        >
          ›|
        </button>
      </div>
    </div>
  );
}

function ModuleSymbolList({
  geneIds,
  symbolByOpaque,
  datasetRefSets,
}: {
  geneIds: string[];
  symbolByOpaque: Record<string, string>;
  datasetRefSets: Record<string, readonly string[]>;
}) {
  const setsBySymbol = useMemo<Map<string, string>>(() => {
    const out = new Map<string, string>();
    for (const [name, syms] of Object.entries(datasetRefSets)) {
      for (const s of syms ?? []) {
        if (!out.has(s)) out.set(s, name);
      }
    }
    return out;
  }, [datasetRefSets]);

  // Truncate long modules inline; expanding the row shows them all.
  const MAX_INLINE = 8;
  const head = geneIds.slice(0, MAX_INLINE);
  const overflow = Math.max(0, geneIds.length - MAX_INLINE);
  return (
    <span className="text-ink">
      {head.map((gid, i) => {
        const sym = symbolByOpaque[gid];
        const setName = sym ? setsBySymbol.get(sym) : undefined;
        const pal = setName ? colorForSet(setName) : null;
        return (
          <Fragment key={gid}>
            <span
              style={pal ? { color: pal.stroke, fontWeight: 600 } : undefined}
            >
              {sym && sym.length > 0
                ? sym
                : sym === ""
                  ? "—"
                  : "(revealing…)"}
            </span>
            {i < head.length - 1 ? ", " : null}
          </Fragment>
        );
      })}
      {overflow > 0 && (
        <span className="text-muted"> · +{overflow}</span>
      )}
    </span>
  );
}

// Compact ✓ / ✗ / "n too small" chips for a module's site + purity
// survival flags. Both are HNSC/HPV-only — for other datasets we don't
// render the column at all.
function SurvivalChips({
  module,
  subgroups,
}: {
  module: RankedModule;
  subgroups: ModuleRanking["subgroups"];
}) {
  // Full-cohort baseline ("pre" in the pre→post pairing). Reused by
  // every chip so the user sees what each subgroup is being compared to.
  const pre = module.combined_holdout;
  function chip(
    label: string,
    survives: boolean | null | undefined,
    post: number | null | undefined,
    title: string,
  ) {
    let bg = "#F4F2EE";
    let stroke = "#6E7F8C";
    let glyph: string;
    if (survives === true) {
      bg = "#E3F2EA";
      stroke = "#2F6E4C";
      glyph = "✓";
    } else if (survives === false) {
      bg = "#FBE6E6";
      stroke = "#A64242";
      glyph = "✗";
    } else {
      // n too small / not applicable
      bg = "#F4F2EE";
      stroke = "#9AA0A6";
      glyph = "—";
    }
    // pre → post pair. When the subgroup AUROC is null ("n too small")
    // we drop the arrow + post; the "—" glyph already signals N/A.
    const preText =
      pre != null && Number.isFinite(pre) ? fmtFit(pre, 2) : null;
    const postText =
      post != null && Number.isFinite(post) ? fmtFit(post, 2) : null;
    let valueText = "";
    if (preText && postText) {
      const delta = pre! - post!;
      const deltaText =
        Number.isFinite(delta)
          ? ` (${delta >= 0 ? "−" : "+"}${Math.abs(delta).toFixed(2)})`
          : "";
      valueText = ` ${preText} → ${postText}${deltaText}`;
    } else if (preText && !postText) {
      valueText = ` ${preText} → —`;
    } else if (postText && !preText) {
      valueText = ` ${postText}`;
    }
    return (
      <span
        className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
        style={{ color: stroke, backgroundColor: bg, border: `1px solid ${stroke}` }}
        title={title}
      >
        {label} {glyph}{valueText}
      </span>
    );
  }
  return (
    <div className="flex flex-wrap items-center gap-1.5">
      {subgroups?.site && chip(
        "site",
        module.survives_site,
        module.combined_holdout_oropharynx,
        module.survives_site === null || module.survives_site === undefined
          ? `Site (oropharynx) subgroup too small to score honestly (n=${module.n_holdout_oropharynx ?? 0}).`
          : `Module recomputed within the oropharynx held-out subgroup: full-cohort AUROC ${fmtFit(pre, 3)} → subgroup AUROC ${fmtFit(module.combined_holdout_oropharynx, 3)} over n=${module.n_holdout_oropharynx ?? 0} (HPV+ ${module.n_pos_oropharynx ?? 0} / HPV− ${module.n_neg_oropharynx ?? 0}). Survives = within 0.05 of the full-cohort AUROC.`,
      )}
      {subgroups?.purity && chip(
        "purity",
        module.survives_purity,
        module.combined_holdout_highpurity,
        module.survives_purity === null || module.survives_purity === undefined
          ? `High-purity (bottom-tertile immune proxy) subgroup too small to score honestly (n=${module.n_holdout_highpurity ?? 0}; HPV+ ${module.n_pos_highpurity ?? 0} / HPV− ${module.n_neg_highpurity ?? 0}).`
          : `Module recomputed within the high-purity (bottom-tertile immune proxy) held-out subgroup: full-cohort AUROC ${fmtFit(pre, 3)} → subgroup AUROC ${fmtFit(module.combined_holdout_highpurity, 3)} over n=${module.n_holdout_highpurity ?? 0} (HPV+ ${module.n_pos_highpurity ?? 0} / HPV− ${module.n_neg_highpurity ?? 0}). Survives = within 0.05 of the full-cohort AUROC.`,
      )}
    </div>
  );
}

// Expanded-row strip: the stratified AUROCs in numbers next to the
// per-gene metrics table, so users can see exactly how far the module
// dropped in each subgroup.
function StratifiedAUROCStrip({
  module,
  subgroups,
  metricKind,
}: {
  module: RankedModule;
  subgroups: ModuleRanking["subgroups"];
  metricKind: "auroc" | "spearman";
}) {
  const label = metricKind === "auroc" ? "AUROC" : "|ρ|";
  function row(
    name: string,
    value: number | null | undefined,
    n: number | undefined,
    n_pos: number | undefined,
    n_neg: number | undefined,
  ) {
    if (n == null && value == null) return null;
    return (
      <div className="flex items-baseline gap-3 text-[11px] text-muted">
        <span className="font-semibold text-ink w-16">{name}</span>
        <span className="font-mono text-ink">
          {label} {fmtFit(value, 3)}
        </span>
        <span className="font-mono">
          n={n ?? 0}
          {metricKind === "auroc" && n_pos != null && n_neg != null
            ? ` (HPV+ ${n_pos} / HPV− ${n_neg})`
            : ""}
        </span>
      </div>
    );
  }
  return (
    <div className="mt-3 space-y-1 rounded-md border border-border bg-card px-3 py-2">
      <div className="text-[11px] font-semibold text-muted">
        Stratified held-out AUROCs (survival check)
      </div>
      <div className="flex items-baseline gap-3 text-[11px] text-muted">
        <span className="font-semibold text-ink w-16">full</span>
        <span className="font-mono text-ink">
          {label} {fmtFit(module.combined_holdout, 3)}
        </span>
      </div>
      {subgroups?.site && row(
        "oropharynx",
        module.combined_holdout_oropharynx,
        module.n_holdout_oropharynx,
        module.n_pos_oropharynx,
        module.n_neg_oropharynx,
      )}
      {subgroups?.purity && row(
        "high purity",
        module.combined_holdout_highpurity,
        module.n_holdout_highpurity,
        module.n_pos_highpurity,
        module.n_neg_highpurity,
      )}
    </div>
  );
}

// The actual best-fitness program tree for a Groups-table row.
// Re-uses the shared <ProgramGraph> renderer (same one the Result
// panel uses for the winner) so the visual matches. The module's
// genes were already revealed in the table-row expand, so the tree
// can show their symbols too — no additional reveal needed.
function ModuleBestProgram({
  programRepr,
  outputLabel,
  symbolByOpaque,
}: {
  programRepr: string;
  outputLabel: string;
  symbolByOpaque: Record<string, string>;
}) {
  // Adapt the flat opaque→symbol map to the {symbol, matched} shape
  // ProgramGraph expects. `matched` is false here — the reference-
  // set highlight is for the known-marker recovery diagnostic, not
  // for a group's own gene tree.
  const reveal = useMemo<
    Record<string, { symbol: string; matched: boolean }>
  >(() => {
    const out: Record<string, { symbol: string; matched: boolean }> = {};
    for (const [opq, sym] of Object.entries(symbolByOpaque)) {
      if (sym && sym.length > 0) {
        out[opq] = { symbol: sym, matched: false };
      }
    }
    return out;
  }, [symbolByOpaque]);

  return (
    <div className="mt-3 rounded-md border border-border bg-card px-3 py-3">
      <div className="text-[11px] font-semibold tracking-wide text-muted">
        This group&rsquo;s best program
      </div>
      <p className="mt-0.5 text-[11px] italic text-muted">
        The actual tree of the candidate that earned the
        Genetic-programming fitness above (argmax over the
        persisted population for this gene-set).
      </p>
      <div className="mt-2">
        <ProgramGraph
          programRepr={programRepr}
          outputLabel={outputLabel}
          reveal={reveal}
        />
      </div>
      <div className="mt-2 flex items-center justify-between gap-2">
        <div className="text-[10.5px] uppercase tracking-wide text-muted">
          program (raw)
        </div>
        <CopyButton
          text={programRepr}
          label="Copy program"
          ariaLabel="Copy this group's best program text"
        />
      </div>
      <div className="mt-1 break-all font-mono text-[12px] text-ink">
        {programRepr}
      </div>
    </div>
  );
}

function ModuleGeneTable({
  module,
  symbolByOpaque,
  datasetRefSets,
  metricKind,
}: {
  module: RankedModule;
  symbolByOpaque: Record<string, string>;
  datasetRefSets: Record<string, readonly string[]>;
  metricKind: "auroc" | "spearman";
}) {
  // Symbol → ref-set names lookup so each row can be tinted by the
  // specific set its gene belongs to (p16 deep gold vs cell_cycle
  // muted amber, etc).
  const setsBySymbol = useMemo<Map<string, string>>(() => {
    const out = new Map<string, string>();
    for (const [name, syms] of Object.entries(datasetRefSets)) {
      for (const s of syms ?? []) {
        // First-write-wins; the priority below also normalises this
        // so p16 (single canonical) outranks cell_cycle for a gene
        // that's in both (CDKN2A isn't, but a future split could be).
        if (!out.has(s)) out.set(s, name);
      }
    }
    return out;
  }, [datasetRefSets]);

  return (
    <div className="overflow-hidden rounded-md border border-border">
      <table className="w-full text-xs">
        <thead className="bg-card text-[10px] tracking-wide text-muted">
          <tr>
            <th className="px-3 py-1.5 text-left">Opaque ID</th>
            <th className="px-3 py-1.5 text-left">Symbol</th>
            <th className="px-3 py-1.5 text-left">Source</th>
            <th className="px-3 py-1.5 text-right">
              {metricKind === "auroc" ? "Single-gene AUROC" : "Single-gene ρ"}
            </th>
            <th className="px-3 py-1.5 text-right">Rank / N</th>
          </tr>
        </thead>
        <tbody>
          {module.per_gene.map((g) => {
            const sym = symbolByOpaque[g.id];
            const setName = sym ? setsBySymbol.get(sym) : undefined;
            const pal = setName ? colorForSet(setName) : null;
            return (
              <tr
                key={g.id}
                className="border-t border-border"
                style={pal ? { backgroundColor: pal.bg } : undefined}
              >
                <td className="px-3 py-1.5 font-mono text-ink">{g.id}</td>
                <td
                  className="px-3 py-1.5"
                  style={pal ? { color: pal.stroke, fontWeight: 600 } : undefined}
                >
                  {sym && sym.length > 0
                    ? sym
                    : sym === ""
                      ? "—"
                      : "(revealing…)"}
                </td>
                <td className="px-3 py-1.5">
                  {setName ? (
                    <span
                      className="rounded-md px-1.5 py-0.5 text-[10px] font-semibold"
                      style={{
                        color: pal!.stroke,
                        backgroundColor: pal!.bg,
                        border: `1px solid ${pal!.stroke}`,
                      }}
                    >
                      {setName}
                    </span>
                  ) : (
                    <span className="text-muted">—</span>
                  )}
                </td>
                <td className="px-3 py-1.5 text-right font-mono text-ink">
                  {fmtFit(g.single_gene_metric, 3)}
                </td>
                <td className="px-3 py-1.5 text-right font-mono text-muted">
                  {g.rank != null && g.total != null
                    ? `${g.rank} / ${g.total.toLocaleString()}`
                    : "—"}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
}

function RankList({
  title,
  subtitle,
  rows,
  total,
}: {
  title: string;
  subtitle: string;
  rows: GeneRankRow[];
  total: number;
}) {
  return (
    <div className="rounded-md border border-border bg-bg px-3 py-2">
      <div className="text-[11px] font-semibold tracking-wide text-muted">
        {title}
      </div>
      <p className="mt-0.5 text-[11px] italic text-muted">{subtitle}</p>
      <ul className="mt-2 space-y-1 text-[12px]">
        {rows.map((r) => (
          <li
            key={r.symbol}
            className="flex items-baseline justify-between gap-2 font-mono"
          >
            <span className="text-ink">{r.symbol}</span>
            {r.present ? (
              <span className="text-muted">
                <span className="text-ink">{r.corr!.toFixed(4)}</span>
                {" · "}
                <span className="text-ink">{r.rank}</span>
                <span className="text-muted">{` / ${total}`}</span>
              </span>
            ) : (
              <span className="text-muted italic">not present</span>
            )}
          </li>
        ))}
      </ul>
    </div>
  );
}

function OverlapSummary({
  k,
  n,
  setName,
  matchedSymbols,
  mlh1,
  totalGenes,
}: {
  k: number;
  n: number;
  setName: ReferenceSetKey;
  matchedSymbols: string[];
  mlh1?: GeneRankRow;
  totalGenes?: number;
}) {
  // For the TMB objective, soften the overlap line: programs rarely
  // contain MMR genes literally, but the diagnostic shows MLH1 itself
  // sits near the top of the negative-TMB ranking.
  const mlh1Tail =
    mlh1?.present && mlh1.rank != null && totalGenes
      ? (
          <span className="mt-1 block text-xs italic text-muted">
            Winning programs rarely ARE the MMR genes (many co-silenced genes
            outrank them) — but MLH1 itself sits in the top{" "}
            {Math.round((mlh1.rank / totalGenes) * 1000) / 10}%. See the
            diagnostic below.
          </span>
        )
      : null;

  if (k === 0) {
    return (
      <span>
        <span>
          No matches — none of the {n} winning gene{n === 1 ? "" : "s"}{" "}
          {n === 1 ? "is" : "are"} in the{" "}
          <span className="font-semibold">{setName}</span> set.
        </span>
        {mlh1Tail}
      </span>
    );
  }
  return (
    <span>
      <span>
        <strong className="text-ink">{k}</strong> of {n} winning genes{" "}
        {k === 1 ? "is" : "are"} in the{" "}
        <span className="font-semibold">{setName}</span> set:{" "}
        <span className="font-mono text-ink">{matchedSymbols.join(", ")}</span>
      </span>
      {mlh1Tail}
    </span>
  );
}
