oncodsl / tests /test_engine_v2.py
govindbalki's picture
Upload folder using huggingface_hub
0fff343 verified
Raw
History Blame Contribute Delete
37.1 kB
"""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