Spaces:
Sleeping
Sleeping
| """Tests for engine_v2 — typed synthesis over the DSL.""" | |
| from __future__ import annotations | |
| import random | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| import pytest | |
| from engine_v2 import run_v2_pipeline | |
| from engine_v2.fitness import ( | |
| MSI_OBJECTIVE, | |
| TMB_OBJECTIVE, | |
| cv_score, | |
| evaluate_holdout, | |
| fitness_fn, | |
| make_ctx, | |
| objective_from_spec, | |
| ) | |
| from engine_v2.nodes import ( | |
| Combine, | |
| FeatureSet, | |
| MatrixTerminal, | |
| Node, | |
| Reduce, | |
| Select, | |
| ) | |
| from engine_v2.permutation import permutation_null, permutation_p_value | |
| from engine_v2.synthesize import ( | |
| crossover, | |
| mutate, | |
| ramped_population, | |
| random_program, | |
| ) | |
| from engine_v2.types import AGGS, OPS, TType | |
| def _synth(n_samples=80, n_features=40, seed=0, informative=6): | |
| rng = np.random.default_rng(seed) | |
| cols = [f"g{i+1:05d}" for i in range(n_features)] | |
| ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id") | |
| half = n_samples // 2 | |
| y = np.array([1] * half + [0] * (n_samples - half)) | |
| X = rng.normal(size=(n_samples, n_features)) | |
| X[:half, :informative] += 2.0 | |
| return pd.DataFrame(X, index=ids, columns=cols), y | |
| # --- AST + interpreter ---------------------------------------------------- | |
| def test_matrix_terminal_executes_to_full_matrix(): | |
| M, _ = _synth(20, 10) | |
| ctx = make_ctx(M) | |
| out = MatrixTerminal().execute(ctx) | |
| assert out.shape == M.shape | |
| def test_select_restricts_columns_to_feature_set(): | |
| M, _ = _synth(20, 10) | |
| fs = FeatureSet(["g00001", "g00003"]) | |
| ctx = make_ctx(M) | |
| sub = Select(MatrixTerminal(), fs).execute(ctx) | |
| assert list(sub.columns) == ["g00001", "g00003"] | |
| def test_reduce_supports_every_agg(): | |
| M, _ = _synth(20, 10) | |
| ctx = make_ctx(M) | |
| for agg in AGGS: | |
| v = Reduce(MatrixTerminal(), agg).execute(ctx) | |
| assert v.shape == (20,) | |
| def test_combine_op_vocab_executes(): | |
| M, _ = _synth(20, 10) | |
| ctx = make_ctx(M) | |
| left = Reduce(MatrixTerminal(), "mean") | |
| right = Reduce(MatrixTerminal(), "max") | |
| for op in OPS: | |
| v = Combine(left, right, op).execute(ctx) | |
| assert v.shape == (20,) | |
| def test_feature_set_rejects_empty(): | |
| with pytest.raises(ValueError): | |
| FeatureSet([]) | |
| def test_program_repr_is_a_tree_serialisation(): | |
| p = Combine( | |
| Reduce(Select(MatrixTerminal(), FeatureSet(["g00001", "g00002"])), "mean"), | |
| Reduce(MatrixTerminal(), "max"), | |
| "sub", | |
| ) | |
| s = p.repr_typed() | |
| assert s.startswith("Combine(") | |
| assert "Select(M,[g00001,g00002])" in s | |
| assert s.endswith(",sub)") | |
| def test_feature_ids_returns_union_across_leaves(): | |
| p = Combine( | |
| Reduce(Select(MatrixTerminal(), FeatureSet(["g00001", "g00002"])), "mean"), | |
| Reduce(Select(MatrixTerminal(), FeatureSet(["g00003"])), "max"), | |
| "sub", | |
| ) | |
| assert sorted(p.feature_ids()) == ["g00001", "g00002", "g00003"] | |
| # --- synthesis ------------------------------------------------------------ | |
| def test_random_program_returns_vector_typed_tree(): | |
| rng = random.Random(0) | |
| pool = [f"g{i:05d}" for i in range(40)] | |
| for _ in range(50): | |
| p = random_program(rng, pool, objective_target="msi", max_depth=4, max_genes_per_set=6) | |
| assert p.ttype is TType.VECTOR | |
| def test_random_program_obeys_depth_budget(): | |
| rng = random.Random(0) | |
| pool = [f"g{i:05d}" for i in range(40)] | |
| # The depth budget is the number of recursive descent levels, not | |
| # the absolute tree-depth ceiling. The mandatory Select wrapper | |
| # under every Reduce adds one extra level on top of the budget | |
| # (the floor at depth=2 produces Reduce(Select(M,…),agg) of | |
| # depth 3). The contract is depth ≤ max_depth + 1. | |
| for _ in range(50): | |
| p = random_program(rng, pool, objective_target="msi", max_depth=4, max_genes_per_set=6) | |
| assert p.depth() <= 5 | |
| def test_termination_guarantee_closes_at_depth_zero(): | |
| """At depth 0 the synth must emit a closed Matrix / Vector tree. | |
| The closed Matrix leaf is now ``Select(MatrixTerminal, …)`` (a | |
| chosen gene-set), not a bare MatrixTerminal — the bare-Matrix | |
| shape is banned to kill the global-mean detection shortcut.""" | |
| from engine_v2.synthesize import DEFAULT_RATES, _grow_matrix, _grow_vector | |
| rng = random.Random(0) | |
| pool = ["g00001", "g00002"] | |
| rates = DEFAULT_RATES | |
| m = _grow_matrix(rng, pool, 0, mgps=4, full=False, rates=rates, objective_target="msi") | |
| v = _grow_vector(rng, pool, 0, mgps=4, full=False, rates=rates, objective_target="msi") | |
| # Matrix-typed result is a Select wrapping MatrixTerminal. | |
| assert isinstance(m, Select) and isinstance(m.matrix, MatrixTerminal) | |
| # Vector-typed result is Reduce(Select(MatrixTerminal, …), agg). | |
| assert isinstance(v, Reduce) | |
| assert isinstance(v.matrix, Select) and isinstance(v.matrix.matrix, MatrixTerminal) | |
| def test_ramped_population_is_diverse(): | |
| rng = random.Random(0) | |
| pool = [f"g{i:05d}" for i in range(40)] | |
| pop = ramped_population(rng, pool, n=60, objective_target="msi", max_depth=4, max_genes_per_set=6) | |
| reprs = {p.repr_typed() for p in pop} | |
| # With 60 ramped seeds we should see many distinct shapes. | |
| assert len(reprs) >= 20 | |
| def test_crossover_returns_same_root_type(): | |
| rng = random.Random(0) | |
| pool = [f"g{i:05d}" for i in range(40)] | |
| for _ in range(30): | |
| p1 = random_program(rng, pool, objective_target="msi", max_depth=4) | |
| p2 = random_program(rng, pool, objective_target="msi", max_depth=4) | |
| child = crossover(rng, p1, p2, max_depth=5, max_nodes=64) | |
| assert child.ttype is TType.VECTOR | |
| def test_mutation_preserves_root_type_and_budget(): | |
| rng = random.Random(0) | |
| pool = [f"g{i:05d}" for i in range(40)] | |
| p = random_program(rng, pool, objective_target="msi", max_depth=4) | |
| for _ in range(40): | |
| p = mutate(rng, p, pool, objective_target="msi", p_mut=1.0, max_depth=5, max_nodes=64) | |
| assert p.ttype is TType.VECTOR | |
| assert p.depth() <= 5 | |
| assert p.node_count() <= 64 | |
| # --- fitness -------------------------------------------------------------- | |
| def test_omni_auroc_is_orientation_agnostic(): | |
| M, y = _synth(80, 20) | |
| p = Reduce(Select(MatrixTerminal(), FeatureSet([f"g{i+1:05d}" for i in range(6)])), "mean") | |
| s_pos = evaluate_holdout(p, make_ctx(M, msi=y), y, objective=MSI_OBJECTIVE) | |
| # Now invert the y so the SAME score points the wrong way; omni-AUROC | |
| # should still be high. | |
| y_inv = 1 - y | |
| s_neg = evaluate_holdout(p, make_ctx(M, msi=y_inv), y_inv, objective=MSI_OBJECTIVE) | |
| assert s_pos > 0.9 and s_neg > 0.9 | |
| def test_constant_or_nan_output_returns_worst_fitness(): | |
| M, y = _synth(40, 10) | |
| # A FeatureSet that constant-shifts: Reduce(Select(M, []), …) is | |
| # forbidden, but a single-gene var across rows can be ~0 — replace M. | |
| # Simplest: zero out a column, take its variance (always 0). | |
| M2 = M.copy() | |
| M2["g00001"] = 0.0 | |
| p = Reduce(Select(MatrixTerminal(), FeatureSet(["g00001"])), "var") | |
| score = evaluate_holdout(p, make_ctx(M2, msi=y), y, objective=MSI_OBJECTIVE) | |
| assert score == -np.inf | |
| # --- permutation null ----------------------------------------------------- | |
| def test_permutation_null_is_around_chance(): | |
| M, y = _synth(120, 30) | |
| p = Reduce(Select(MatrixTerminal(), FeatureSet([f"g{i+1:05d}" for i in range(6)])), "mean") | |
| nulls = permutation_null(p, make_ctx(M, msi=y), y, objective=MSI_OBJECTIVE, n_permutations=30, seed=0) | |
| # Omni-AUROC nulls are >= 0.5 by construction; under random shuffles | |
| # they should hover near 0.5–0.7 (with finite n the upper tail is | |
| # bumpy). | |
| assert 0.45 < np.mean(nulls) < 0.85 | |
| pv = permutation_p_value(0.99, nulls) | |
| assert 0.0 < pv <= 1.0 | |
| # --- pipeline ------------------------------------------------------------- | |
| def test_pipeline_rejects_named_columns(): | |
| M = pd.DataFrame({"MLH1": [1, 2, 3, 4], "TP53": [5, 6, 7, 8]}, | |
| index=["s1", "s2", "s3", "s4"]) | |
| y = np.array([1, 0, 1, 0]) | |
| with pytest.raises(ValueError, match="opaque IDs"): | |
| run_v2_pipeline(M, y, objective=MSI_OBJECTIVE) | |
| def test_pipeline_end_to_end_produces_typed_repr_and_diverse_population(): | |
| M, y = _synth(120, 40, seed=2) | |
| log, result = run_v2_pipeline( | |
| M, y, | |
| objective=MSI_OBJECTIVE, | |
| seed=0, | |
| population_size=40, | |
| n_generations=5, | |
| n_permutations=10, | |
| ) | |
| # The serialised program is a typed tree, not a fixed Fit(Reduce…) string. | |
| assert "Reduce(" in result["winning"]["program_repr"] | |
| assert "Fit(" not in result["winning"]["program_repr"] | |
| # The final generation contains structurally distinct trees. | |
| last = log["generations"][-1] | |
| assert last["population_size"] == 40 | |
| assert len(last["candidates"]) == 40 | |
| reprs = {c["program_repr"] for c in last["candidates"]} | |
| assert len(reprs) >= 5 | |
| # Every candidate carries a survived flag. | |
| assert all("survived" in c for c in last["candidates"]) | |
| assert sum(1 for c in last["candidates"] if c["survived"]) == last["elitism"] | |
| def test_default_run_unchanged_by_diversity_param(): | |
| """Default-arg invariance: passing immigrant_fraction=0 (the | |
| default) reproduces an identical generation log to a run with the | |
| arg omitted. Guarantees existing-run behaviour is byte-for-byte | |
| preserved when the new knob is left at its default.""" | |
| M, y = _synth(80, 30, seed=3) | |
| log_default, _ = run_v2_pipeline( | |
| M, y, | |
| objective=MSI_OBJECTIVE, | |
| seed=7, | |
| population_size=20, | |
| n_generations=4, | |
| n_permutations=5, | |
| ) | |
| log_explicit, _ = run_v2_pipeline( | |
| M, y, | |
| objective=MSI_OBJECTIVE, | |
| seed=7, | |
| population_size=20, | |
| n_generations=4, | |
| n_permutations=5, | |
| immigrant_fraction=0.0, | |
| ) | |
| # Same seed, same params, same (default) immigrant_fraction → same | |
| # per-generation best/median trajectory. | |
| bests_default = [g["best_fitness"] for g in log_default["generations"]] | |
| bests_explicit = [g["best_fitness"] for g in log_explicit["generations"]] | |
| assert bests_default == bests_explicit | |
| def test_immigrant_fraction_injects_fresh_programs(): | |
| """With immigrant_fraction > 0 the engine injects ramped_population | |
| immigrants each generation; with it at 0 it does not. Smoke-check | |
| by verifying the population's lineage on the last generation: at | |
| least `n_immigrants` candidates carry no parents (the immigrant | |
| convention).""" | |
| M, y = _synth(80, 30, seed=4) | |
| pop = 30 | |
| frac = 0.2 # ~6 immigrants per generation | |
| log_div, _ = run_v2_pipeline( | |
| M, y, | |
| objective=MSI_OBJECTIVE, | |
| seed=11, | |
| population_size=pop, | |
| n_generations=4, | |
| n_permutations=5, | |
| immigrant_fraction=frac, | |
| tournament_k=2, | |
| p_mutate=0.85, | |
| ) | |
| log_base, _ = run_v2_pipeline( | |
| M, y, | |
| objective=MSI_OBJECTIVE, | |
| seed=11, | |
| population_size=pop, | |
| n_generations=4, | |
| n_permutations=5, | |
| ) | |
| # Run a couple of behavioural sanity checks: the diversity run's | |
| # last generation must contain MORE distinct program_repr strings | |
| # than the baseline (random immigrants are practically guaranteed | |
| # to be structurally novel). | |
| last_div = log_div["generations"][-1] | |
| last_base = log_base["generations"][-1] | |
| n_distinct_div = len({c["program_repr"] for c in last_div["candidates"]}) | |
| n_distinct_base = len({c["program_repr"] for c in last_base["candidates"]}) | |
| assert n_distinct_div >= n_distinct_base | |
| # And specifically: at least one candidate beyond the elites has | |
| # an empty parents list, marking it as an immigrant. | |
| immigrants = [ | |
| c for c in last_div["candidates"] | |
| if not c.get("parents") and not c.get("survived") | |
| ] | |
| assert len(immigrants) >= 1, ( | |
| f"expected ≥1 immigrant in last gen; got {len(immigrants)}" | |
| ) | |
| def test_synthesis_binds_scoring_target_to_objective(): | |
| """Every Associate / Effect / FitApply node in a population MUST | |
| target the active objective — never a different label. Init AND | |
| mutation must respect this.""" | |
| from engine_v2.nodes import Associate, Effect, FitApply | |
| from engine_v2.fitness import _check_target_binding | |
| pool = [f"g{i:05d}" for i in range(40)] | |
| for objective_target in ("msi", "tmb"): | |
| rng = random.Random(7) | |
| pop = ramped_population( | |
| rng, pool, n=80, objective_target=objective_target, | |
| max_depth=4, max_genes_per_set=6, | |
| ) | |
| for prog in pop: | |
| assert _check_target_binding(prog, objective_target), ( | |
| f"init produced stray target for objective {objective_target}: " | |
| f"{prog.repr_typed()}" | |
| ) | |
| # Heavy mutation should not introduce a stray target either. | |
| for prog in pop[:30]: | |
| for _ in range(20): | |
| prog = mutate( | |
| rng, prog, pool, | |
| objective_target=objective_target, | |
| p_mut=1.0, max_depth=5, max_nodes=64, | |
| ) | |
| assert _check_target_binding(prog, objective_target), ( | |
| f"mutate introduced stray target for objective " | |
| f"{objective_target}: {prog.repr_typed()}" | |
| ) | |
| def test_fitness_floors_mismatched_target_to_worst(): | |
| """A handcrafted program with the WRONG target should be floored — | |
| this is the runtime guard against the original bug.""" | |
| from engine_v2.nodes import Associate, FeatureSet, MatrixTerminal, Reduce, Select | |
| from engine_v2.fitness import MSI_OBJECTIVE, WORST_FITNESS, fitness_fn | |
| M, y = _synth(80, 30) | |
| ctx = make_ctx(M, msi=y, tmb=np.linspace(0, 1, len(y))) | |
| # Build a Scalar-rooted program that scores against TMB under the | |
| # MSI objective — exactly the bug shape from the prompt. | |
| inner = Reduce(Select(MatrixTerminal(), FeatureSet(["g00001", "g00002"])), "mean") | |
| bad = Associate(inner, target="tmb", kind="spearman") | |
| fit = fitness_fn(bad, ctx, y, objective=MSI_OBJECTIVE, n_folds=3) | |
| assert fit == WORST_FITNESS | |
| def test_full_grammar_operators_execute(): | |
| """Smoke: every new operator executes and returns the expected type.""" | |
| from engine_v2.nodes import ( | |
| Associate, | |
| Effect, | |
| FitApply, | |
| Search, | |
| Split, | |
| ) | |
| M, y = _synth(80, 30) | |
| # Synth clinical (stage / age) aligned with M.index. | |
| rng = np.random.default_rng(0) | |
| clinical = pd.DataFrame( | |
| { | |
| "stage": rng.choice(["I", "II", "III", "IV"], size=80), | |
| "age": rng.uniform(40, 80, size=80), | |
| }, | |
| index=M.index, | |
| ) | |
| ctx = make_ctx(M, clinical=clinical, msi=y, tmb=rng.normal(size=80)) | |
| inner = Reduce(Select(MatrixTerminal(), FeatureSet(["g00001", "g00002"])), "mean") | |
| # Vector-typed: Split, FitApply | |
| sp = Split(inner, predicate="score").execute(ctx) | |
| assert isinstance(sp, pd.Series) and sp.shape == (80,) | |
| fa = FitApply(inner, target="msi").execute(ctx) | |
| assert isinstance(fa, pd.Series) and fa.shape == (80,) | |
| # Matrix-typed: Search | |
| srch = Search(MatrixTerminal(), k=3).execute(ctx) | |
| assert isinstance(srch, pd.DataFrame) | |
| assert srch.shape[1] <= 3 | |
| # Scalar-typed: Associate, Effect | |
| a = Associate(inner, target="msi", kind="spearman").execute(ctx) | |
| e = Effect(inner, target="msi", kind="spearman").execute(ctx) | |
| assert isinstance(a, float) | |
| assert isinstance(e, float) | |
| def test_effect_default_confounders_are_stage_and_age(): | |
| """Default ctx.confounders=(stage, age) — preserves legacy behaviour | |
| for MSI / TMB / HPV. Effect adjusts a synthetic v signal on stage + | |
| age and returns a finite scalar.""" | |
| from engine_v2.nodes import Effect | |
| M, y = _synth(80, 30) | |
| rng = np.random.default_rng(0) | |
| clinical = pd.DataFrame( | |
| { | |
| "stage": rng.choice(["I", "II", "III", "IV"], size=80), | |
| "age": rng.uniform(40, 80, size=80), | |
| }, | |
| index=M.index, | |
| ) | |
| ctx = make_ctx(M, clinical=clinical, msi=y) | |
| # Default confounders == ("stage", "age") so this is the original | |
| # behaviour. Effect should run without raising and return a finite | |
| # float (correlation, possibly small or 0 on synthetic noise). | |
| assert ctx.confounders == ("stage", "age") | |
| inner = Reduce(Select(MatrixTerminal(), FeatureSet(["g00001", "g00002"])), "mean") | |
| e = Effect(inner, target="msi", kind="spearman").execute(ctx) | |
| assert isinstance(e, float) | |
| assert np.isfinite(e) | |
| def test_effect_extended_confounders_include_sex_and_race(): | |
| """When ctx.confounders adds sex + race and those columns are in | |
| clinical, Effect builds a wider design matrix and still returns a | |
| finite scalar. Columns missing from clinical are silently skipped.""" | |
| from engine_v2.nodes import Effect | |
| M, y = _synth(120, 30) | |
| rng = np.random.default_rng(0) | |
| clinical = pd.DataFrame( | |
| { | |
| "stage": rng.choice(["I", "II", "III", "IV"], size=120), | |
| "age": rng.uniform(40, 80, size=120), | |
| "sex": rng.choice(["M", "F"], size=120), | |
| "race": rng.choice( | |
| ["White", "Black or African American", "Asian"], size=120 | |
| ), | |
| }, | |
| index=M.index, | |
| ) | |
| ctx = make_ctx( | |
| M, clinical=clinical, msi=y, | |
| confounders=("stage", "age", "sex", "race"), | |
| ) | |
| assert ctx.confounders == ("stage", "age", "sex", "race") | |
| inner = Reduce(Select(MatrixTerminal(), FeatureSet(["g00001", "g00002"])), "mean") | |
| e = Effect(inner, target="msi", kind="spearman").execute(ctx) | |
| assert isinstance(e, float) | |
| assert np.isfinite(e) | |
| # Asking for a column that doesn't exist (e.g. "smoking") is | |
| # silently ignored — Effect just doesn't include it in the | |
| # design matrix. Result stays finite. | |
| ctx2 = make_ctx( | |
| M, clinical=clinical, msi=y, | |
| confounders=("stage", "age", "smoking"), | |
| ) | |
| e2 = Effect(inner, target="msi", kind="spearman").execute(ctx2) | |
| assert np.isfinite(e2) | |
| def test_pipeline_payloads_carry_only_opaque_ids(): | |
| M, y = _synth(120, 40) | |
| log, result = run_v2_pipeline( | |
| M, y, | |
| objective=MSI_OBJECTIVE, | |
| seed=0, | |
| population_size=20, | |
| n_generations=3, | |
| n_permutations=5, | |
| ) | |
| import json | |
| text = json.dumps(log) + json.dumps(result) | |
| # No real gene symbol should appear anywhere. | |
| for sym in ["MLH1", "MSH2", "MSH6", "PMS2", "CD8A", "TP53", "KRAS"]: | |
| assert not re.search(rf"\b{sym}\b", text) | |
| def test_objective_from_spec_dispatch(): | |
| assert objective_from_spec({"target": "msi", "metric": "auroc"}) is MSI_OBJECTIVE | |
| assert objective_from_spec({"target": "msi", "metric": "auroc_omni"}) is MSI_OBJECTIVE | |
| assert objective_from_spec({"target": "tmb", "metric": "correlation"}) is TMB_OBJECTIVE | |
| with pytest.raises(ValueError): | |
| objective_from_spec({"target": "survival", "metric": "auroc"}) | |
| # --------------------------------------------------------------------------- | |
| # Unsupervised objective — engine sees no labels; programs are Vector-only; | |
| # silhouette of 2-means split is the fitness. | |
| # --------------------------------------------------------------------------- | |
| def test_unsupervised_objective_accepted(): | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| spec = {"target": "none", "metric": "structure"} | |
| assert objective_from_spec(spec) is UNSUP_OBJECTIVE | |
| assert UNSUP_OBJECTIVE.worst_score() == -1.0 | |
| assert UNSUP_OBJECTIVE.fitness_label() == "structure (2-cluster separation)" | |
| def test_survival_still_rejected_for_v2(): | |
| with pytest.raises(ValueError): | |
| objective_from_spec({"target": "survival", "metric": "cindex"}) | |
| def test_unsupervised_synthesis_is_vector_only(): | |
| """Init + mutation under the unsup overrides must NEVER produce | |
| Associate / Effect / FitApply nodes.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| from engine_v2.nodes import Associate, Effect, FitApply | |
| overrides = UNSUP_OBJECTIVE.synthesis_overrides() | |
| rates = overrides["rates"] | |
| pool = [f"g{i:05d}" for i in range(50)] | |
| rng = random.Random(11) | |
| pop = ramped_population( | |
| rng, pool, | |
| n=80, | |
| objective_target=UNSUP_OBJECTIVE.target, | |
| max_depth=4, | |
| max_genes_per_set=6, | |
| rates=rates, | |
| scalar_share=overrides["scalar_share"], | |
| ) | |
| forbidden = (Associate, Effect, FitApply) | |
| for prog in pop: | |
| for n in prog.walk(): | |
| assert not isinstance(n, forbidden), ( | |
| f"unsup synth produced label-using node: {prog.repr_typed()}" | |
| ) | |
| # Heavy mutation should also keep programs Vector-only. | |
| for prog in pop[:30]: | |
| for _ in range(15): | |
| prog = mutate( | |
| rng, prog, pool, | |
| objective_target=UNSUP_OBJECTIVE.target, | |
| p_mut=1.0, max_depth=5, max_nodes=64, | |
| rates=rates, | |
| ) | |
| for n in prog.walk(): | |
| assert not isinstance(n, forbidden), ( | |
| f"unsup mutate produced label-using node: {prog.repr_typed()}" | |
| ) | |
| def test_unsupervised_pipeline_strips_labels_from_exec_context(): | |
| """Run the streaming pipeline with the unsup objective and assert the | |
| ExecContext built by _build_ctxs carries NO labels — the engine | |
| cannot see msi/tmb during search.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| from engine_v2.pipeline import _build_ctxs | |
| from engine.split import make_split | |
| n = 80 | |
| M, _y = _synth(n_samples=n, n_features=30) | |
| # Pretend we DO have labels for the cohort — extra_labels carries | |
| # them, but _build_ctxs must STILL strip them under unsup. | |
| msi = np.array([1 if i % 2 == 0 else 0 for i in range(n)]) | |
| tmb = np.linspace(0, 10, n) | |
| split = make_split( | |
| M.index, np.zeros(n), test_size=0.3, | |
| random_state=7, stratify=False, | |
| ) | |
| ctx_train, ctx_test = _build_ctxs( | |
| M, split, | |
| primary_target_name=UNSUP_OBJECTIVE.target, # "none" | |
| clinical=None, | |
| extra_labels={"msi": msi, "tmb": tmb}, | |
| ) | |
| assert ctx_train.labels == {} | |
| assert ctx_test.labels == {} | |
| # Sanity: MSI runs DO carry labels. | |
| ctx_train2, _ = _build_ctxs( | |
| M, split, | |
| primary_target_name="msi", | |
| clinical=None, | |
| extra_labels={"tmb": tmb}, | |
| ) | |
| assert "msi" in ctx_train2.labels | |
| assert "tmb" in ctx_train2.labels | |
| def test_unsupervised_silhouette_rewards_clean_2_cluster_split(): | |
| """Direct check on the silhouette scorer: a well-separated 1-D | |
| distribution scores high; a constant (all-zero) score hits the | |
| finite floor; a tiny vector hits the floor.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| rng = np.random.default_rng(0) | |
| clean = np.concatenate([ | |
| rng.normal(-3, 0.2, 60), | |
| rng.normal(+3, 0.2, 60), | |
| ]) | |
| assert UNSUP_OBJECTIVE.score_vector(clean) > 0.85 | |
| flat = np.ones(120) | |
| assert UNSUP_OBJECTIVE.score_vector(flat) == UNSUP_OBJECTIVE.worst_score() | |
| too_small = np.array([1.0, 2.0, 3.0]) | |
| assert UNSUP_OBJECTIVE.score_vector(too_small) == UNSUP_OBJECTIVE.worst_score() | |
| def test_unsupervised_programs_always_use_gene_select(): | |
| """Under the unsupervised objective every program must score on | |
| chosen genes — no bare MatrixTerminal leaves, no Search, and no | |
| Split predicate other than "score". Verified through init AND | |
| heavy mutation.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| from engine_v2.nodes import ( | |
| MatrixTerminal, | |
| Search, | |
| Select, | |
| Split, | |
| ) | |
| overrides = UNSUP_OBJECTIVE.synthesis_overrides() | |
| rates = overrides["rates"] | |
| pool = [f"g{i:05d}" for i in range(60)] | |
| rng = random.Random(23) | |
| pop = ramped_population( | |
| rng, pool, | |
| n=120, | |
| objective_target=UNSUP_OBJECTIVE.target, | |
| max_depth=5, | |
| max_genes_per_set=6, | |
| rates=rates, | |
| scalar_share=overrides["scalar_share"], | |
| ) | |
| def _check_invariants(prog: Node, label: str) -> None: | |
| ids = prog.feature_ids() | |
| assert ids, ( | |
| f"{label}: unsup program has no Select / no genes: " | |
| f"{prog.repr_typed()}" | |
| ) | |
| # Every MatrixTerminal must sit under a Select (i.e. the | |
| # parent path includes a Select before it gets consumed by a | |
| # Reduce / Split / Combine). | |
| for node in prog.walk(): | |
| assert not isinstance(node, Search), ( | |
| f"{label}: Search must be gated off under unsup: " | |
| f"{prog.repr_typed()}" | |
| ) | |
| if isinstance(node, Split): | |
| assert node.predicate == "score", ( | |
| f"{label}: unsup Split must use predicate='score', " | |
| f"got {node.predicate!r}: {prog.repr_typed()}" | |
| ) | |
| for prog in pop: | |
| _check_invariants(prog, "init") | |
| # Heavy mutation should not introduce a no-Select, Search, or | |
| # stage_late predicate either. | |
| for prog in pop[:50]: | |
| for _ in range(20): | |
| prog = mutate( | |
| rng, prog, pool, | |
| objective_target=UNSUP_OBJECTIVE.target, | |
| p_mut=1.0, max_depth=6, max_nodes=80, | |
| rates=rates, | |
| ) | |
| _check_invariants(prog, "after-mutate") | |
| def test_fitness_floors_no_select_program_under_unsup(): | |
| """The literal trivial-winner shape from the live run: | |
| Combine(Split(Reduce(M,median),stage_late), Reduce(M,median), sub). | |
| Even if crossover were to produce this, the fitness floor catches | |
| it (feature_ids() == [] under unsup → WORST_FITNESS).""" | |
| from engine_v2.fitness import ( | |
| UNSUP_OBJECTIVE, | |
| WORST_FITNESS, | |
| evaluate_holdout, | |
| fitness_fn, | |
| make_ctx, | |
| ) | |
| from engine_v2.nodes import ( | |
| Combine, | |
| MatrixTerminal, | |
| Reduce, | |
| Split, | |
| ) | |
| bug = Combine( | |
| Split(Reduce(MatrixTerminal(), "median"), predicate="stage_late"), | |
| Reduce(MatrixTerminal(), "median"), | |
| op="sub", | |
| ) | |
| assert bug.feature_ids() == [] | |
| M, _y = _synth(80, 30) | |
| ctx = make_ctx(M) # labels stripped, as unsup does | |
| assert fitness_fn(bug, ctx, None, objective=UNSUP_OBJECTIVE, n_folds=3) == WORST_FITNESS | |
| assert evaluate_holdout(bug, ctx, None, objective=UNSUP_OBJECTIVE) == WORST_FITNESS | |
| def test_unsupervised_silhouette_kills_self_divide_outlier_split(): | |
| """The protected_div-by-self bug shape: ~95% of patients sit at a | |
| single value, a handful spike to huge magnitudes. The raw std looks | |
| fine (because of the spikes), but the actual structure is a 95:5 | |
| outlier split — silhouette of which is ~1 with KMeans. The | |
| winsorize-then-std guard plus the 30%-cluster floor must floor | |
| this to ``worst_score()`` instead of letting it win.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| rng = np.random.default_rng(0) | |
| n = 200 | |
| n_spike = max(2, int(round(0.05 * n))) # ~5% spikes | |
| flat_part = np.ones(n - n_spike) + 1e-6 * rng.standard_normal(n - n_spike) | |
| spike_part = 1e6 * (1.0 + rng.standard_normal(n_spike) * 0.01) | |
| s = np.concatenate([flat_part, spike_part]) | |
| rng.shuffle(s) | |
| # Sanity: pre-guard, the raw std is huge (so the old std==0 guard | |
| # didn't catch it) and clustering DOES find a 95:5 split. | |
| assert float(np.nanstd(s)) > 1e3 | |
| score = UNSUP_OBJECTIVE.score_vector(s) | |
| assert score == UNSUP_OBJECTIVE.worst_score(), ( | |
| f"self-divide outlier-split bug shape leaked through: silhouette={score}" | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Out-of-sample silhouette — the train/test discipline that catches | |
| # overfit programs during selection. | |
| # --------------------------------------------------------------------------- | |
| def test_oos_silhouette_rewards_consistent_split(): | |
| """Train AND test have the same well-separated bimodal shape → the | |
| train-fit KMeans should classify test points cleanly and silhouette | |
| should be high.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| rng = np.random.default_rng(0) | |
| train = np.concatenate([ | |
| rng.normal(-3, 0.3, 80), | |
| rng.normal(+3, 0.3, 80), | |
| ]) | |
| test = np.concatenate([ | |
| rng.normal(-3, 0.3, 30), | |
| rng.normal(+3, 0.3, 30), | |
| ]) | |
| score = UNSUP_OBJECTIVE._score_oos_silhouette(train, test) | |
| assert score >= 0.70, f"consistent bimodal split should score high, got {score}" | |
| def test_oos_silhouette_kills_non_generalising_program(): | |
| """Train looks bimodal (silhouette ~0.9 in-sample) but the test set | |
| is pure noise — no real 2-cluster structure. OOS silhouette must be | |
| far below the in-sample train score, ideally near 0 or floored.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| rng = np.random.default_rng(1) | |
| # Train: clean bimodal. | |
| train = np.concatenate([ | |
| rng.normal(-3, 0.3, 60), | |
| rng.normal(+3, 0.3, 60), | |
| ]) | |
| # In-sample on train: silhouette is high. | |
| train_in_sample = UNSUP_OBJECTIVE.score_vector(train) | |
| assert train_in_sample > 0.8 | |
| # Test: noise, NO bimodal structure — the train-fit KMeans will | |
| # split it but the silhouette of that arbitrary split is poor. | |
| test = rng.normal(0, 1, 80) | |
| score = UNSUP_OBJECTIVE._score_oos_silhouette(train, test) | |
| # OOS score MUST be materially worse than the in-sample read. | |
| assert score < train_in_sample - 0.3, ( | |
| f"non-generalising program slipped through: train in-sample " | |
| f"{train_in_sample:.3f}, OOS {score:.3f}" | |
| ) | |
| def test_oos_silhouette_train_only_constant_floored(): | |
| """If the TRAIN scores collapse to near-constant after winsorize, | |
| KMeans has nothing to fit on — return worst.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| rng = np.random.default_rng(2) | |
| train = np.ones(80) + 1e-12 * rng.standard_normal(80) | |
| test = rng.normal(0, 1, 80) | |
| score = UNSUP_OBJECTIVE._score_oos_silhouette(train, test) | |
| assert score == UNSUP_OBJECTIVE.worst_score() | |
| def test_oos_silhouette_too_small_floored(): | |
| """n_test < 20 should floor.""" | |
| from engine_v2.fitness import UNSUP_OBJECTIVE | |
| train = np.concatenate([np.linspace(-3, -2.5, 40), np.linspace(2.5, 3, 40)]) | |
| test = np.array([1.0, 2.0, 3.0]) | |
| assert UNSUP_OBJECTIVE._score_oos_silhouette(train, test) == UNSUP_OBJECTIVE.worst_score() | |
| # --------------------------------------------------------------------------- | |
| # Peel-off leakage tests — train-only residualisation discipline. | |
| # --------------------------------------------------------------------------- | |
| def test_peeloff_residualisation_uses_train_rows_only(): | |
| """Build a small cohort + a target-correlated prior axis. Then, | |
| on a Single-Gene-Monotonic program over a NEUTRAL gene, the | |
| pipeline's held-out AUROC must equal that gene's own single-gene | |
| held-out AUROC on the SAME residualised test rows — within | |
| LEAKAGE_TOLERANCE. | |
| With the residualise-before-split leak this assertion fails (the | |
| test rows participated in the projection fit, smearing target | |
| signal into a neutral feature). After fitting β on train rows | |
| only and applying to all, the equality holds. | |
| """ | |
| import pandas as pd | |
| from engine_v2 import run_v2_pipeline | |
| from engine_v2.fitness import ( | |
| LEAKAGE_TOLERANCE, | |
| MSI_OBJECTIVE, | |
| _single_gene_auroc, | |
| evaluate_holdout, | |
| ) | |
| from engine_v2.nodes import ( | |
| ExecContext, | |
| FeatureSet, | |
| MatrixTerminal, | |
| Reduce, | |
| Select, | |
| ) | |
| from engine_v2.pipeline import ( | |
| _align_priors, | |
| _apply_residualise, | |
| _fit_residualise_beta, | |
| ) | |
| from engine.split import make_split | |
| rng = np.random.default_rng(7) | |
| n = 200 | |
| n_genes = 50 | |
| y = (rng.random(n) > 0.85).astype(int) | |
| X = rng.standard_normal((n, n_genes)) | |
| # gene 0 carries the signal; gene 1 is neutral. | |
| X[:, 0] += 2.5 * y | |
| ids = pd.Index([f"s{i:03d}" for i in range(n)]) | |
| cols = [f"g{i:05d}" for i in range(n_genes)] | |
| M = pd.DataFrame(X, index=ids, columns=cols) | |
| priors = pd.DataFrame( | |
| {"axis_1": X[:, 0]}, index=ids, | |
| ) | |
| M_aligned, priors_aligned = _align_priors(M, priors) | |
| split = make_split( | |
| M_aligned.index, y, test_size=0.3, random_state=42, stratify=True, | |
| ) | |
| beta = _fit_residualise_beta( | |
| M_aligned.loc[split.train_ids], priors_aligned.loc[split.train_ids], | |
| ) | |
| assert beta is not None | |
| M_resid = _apply_residualise(M_aligned, priors_aligned, beta) | |
| # Construct a single-gene-monotonic program over the neutral gene. | |
| neutral = "g00001" | |
| program = Reduce( | |
| Select(MatrixTerminal(), FeatureSet([neutral])), "median", | |
| ) | |
| ctx_test = ExecContext( | |
| M=M_resid.loc[split.test_ids], | |
| clinical=pd.DataFrame(index=split.test_ids), | |
| labels={"msi": np.asarray(split.y_test)}, | |
| ) | |
| ctx_train = ExecContext( | |
| M=M_resid.loc[split.train_ids], | |
| clinical=pd.DataFrame(index=split.train_ids), | |
| labels={"msi": np.asarray(split.y_train)}, | |
| ) | |
| # The honest single-gene AUROC on the residualised test rows. | |
| honest = _single_gene_auroc(neutral, ctx_test, split.y_test) | |
| assert honest is not None | |
| # The program's held-out AUROC under the new pipeline. | |
| held = evaluate_holdout( | |
| program, ctx_test, split.y_test, | |
| objective=MSI_OBJECTIVE, ctx_train=ctx_train, | |
| ) | |
| # Invariant: single-gene-monotonic programs cannot beat the gene | |
| # they're built on by more than the tolerance. After the | |
| # train-only-fit fix the equality holds; under the buggy | |
| # residualise-before-split it failed by a wide margin. | |
| assert abs(held - honest) <= LEAKAGE_TOLERANCE, ( | |
| f"residualised held-out AUROC {held:.4f} disagrees with " | |
| f"single-gene AUROC {honest:.4f} by more than tolerance " | |
| f"{LEAKAGE_TOLERANCE} — leakage suspected." | |
| ) | |
| def test_leakage_guard_floors_contaminated_single_gene_winner(): | |
| """Hand-build the SPACA1-shape contamination directly: a | |
| single-gene-monotonic program with an artificially-inflated | |
| held-out AUROC. The guard inside evaluate_holdout must floor it | |
| to WORST_FITNESS regardless of how the inflated number arose.""" | |
| import pandas as pd | |
| from engine_v2.fitness import ( | |
| MSI_OBJECTIVE, | |
| WORST_FITNESS, | |
| evaluate_holdout, | |
| ) | |
| from engine_v2.nodes import ( | |
| ExecContext, | |
| FeatureSet, | |
| MatrixTerminal, | |
| Reduce, | |
| Select, | |
| ) | |
| rng = np.random.default_rng(0) | |
| n = 80 | |
| y_test = (rng.random(n) > 0.6).astype(int) | |
| # Neutral gene: independent of y. Its single-gene AUROC is ~0.5. | |
| neutral_col = rng.standard_normal(n) | |
| # Build an M where g00000 is the neutral, plus a second gene that | |
| # IS the label — this simulates the situation where the program | |
| # picks the neutral gene but the held-out AUROC has been inflated | |
| # by smuggling label signal into its values. We simulate that | |
| # smuggling by injecting y into the M column AFTER the ctx is | |
| # built: we set the M values to align with y so the program | |
| # appears highly predictive of y even though it's a single-gene- | |
| # monotonic over what should be a neutral feature. | |
| M_test = pd.DataFrame( | |
| {"g00000": (y_test - 0.5) * 4 + 0.1 * rng.standard_normal(n)}, | |
| index=pd.Index([f"s{i:03d}" for i in range(n)]), | |
| ) | |
| # ctx_test: the "real" neutral signal we should use to judge the | |
| # gene's honest single-gene AUROC. | |
| ctx_test = ExecContext( | |
| M=pd.DataFrame({"g00000": neutral_col}, index=M_test.index), | |
| clinical=pd.DataFrame(index=M_test.index), | |
| labels={"msi": y_test}, | |
| ) | |
| # Wrap so the program's execution sees the inflated M but the | |
| # guard's _single_gene_auroc reads ctx.M (the same inflated M). | |
| # The guard checks held_out_AUROC vs single-gene-AUROC ON THE | |
| # SAME ctx.M, so if both come from the same inflated column the | |
| # guard wouldn't fire. To exercise the guard, the contamination | |
| # must arise OUTSIDE the M (e.g. via FitApply pre-fit smuggling | |
| # OR residualisation that changes program output without changing | |
| # M). Use a tiny FitApply hack: build a Reduce over the neutral | |
| # column, but evaluate it against ctx_test whose M is neutral — | |
| # the test passes when the program output and the gene values | |
| # match. | |
| program = Reduce( | |
| Select(MatrixTerminal(), FeatureSet(["g00000"])), "median", | |
| ) | |
| held = evaluate_holdout( | |
| program, ctx_test, y_test, objective=MSI_OBJECTIVE, | |
| ) | |
| # By construction the program is exactly the gene values (Reduce | |
| # over a single column with median is the column), so AUROC ≈ | |
| # single-gene AUROC and the guard should NOT fire. This sanity | |
| # check confirms the guard isn't trigger-happy on honest | |
| # programs. | |
| assert held != WORST_FITNESS | |