"""Smoke tests for the GP engine. Synthetic data only — no network, no parquet.""" from __future__ import annotations import random import numpy as np import pandas as pd import pytest from engine.baseline import make_baseline from engine.fitness import cv_auroc, fitness_fn, program_state from engine.gp import run_gp from engine.permutation import permutation_null, permutation_p_value from engine.pipeline import run_gp_pipeline from engine.prefilter import top_n_features from engine.program import ( MAX_SET_SIZE, Program, crossover, mutate, random_program, ) from engine.split import make_split # --- synthetic data -------------------------------------------------------- def _synthetic_matrix( n_samples: int = 80, n_features: int = 200, n_informative: int = 10, seed: int = 0, ) -> tuple[pd.DataFrame, np.ndarray]: rng = np.random.default_rng(seed) half = n_samples // 2 y = np.array([1] * half + [0] * (n_samples - half)) cols = [f"g{i+1:05d}" for i in range(n_features)] sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id") X = rng.normal(loc=0.0, scale=1.0, size=(n_samples, n_features)) # Inject signal in the first `n_informative` features. X[:half, :n_informative] += 2.5 M = pd.DataFrame(X, index=sample_ids, columns=cols) return M, y # --- prefilter -------------------------------------------------------------- def test_prefilter_surfaces_the_informative_features(): M, y = _synthetic_matrix(n_samples=100, n_features=200, n_informative=10) top, scores = top_n_features(M, y, n=20) informative = {f"g{i+1:05d}" for i in range(10)} overlap = informative & set(top) assert len(overlap) >= 8 # the prefilter should grab most/all of them # --- program operators ----------------------------------------------------- def test_random_program_well_formed(): rng = random.Random(0) pool = [f"g{i:05d}" for i in range(50)] p = random_program(rng, pool) assert 1 <= len(p.feature_sets) <= 2 for fs in p.feature_sets: assert 2 <= len(fs) <= MAX_SET_SIZE assert all(g in pool for g in fs) # Gene IDs should be unique across sets. assert len(p.gene_ids) == len(set(p.gene_ids)) def test_mutate_preserves_invariants(): rng = random.Random(1) pool = [f"g{i:05d}" for i in range(50)] p = random_program(rng, pool) for _ in range(20): p = mutate(rng, p, pool, p_mut=1.0) assert 1 <= len(p.feature_sets) <= 2 for fs in p.feature_sets: assert 2 <= len(fs) <= MAX_SET_SIZE assert len(p.gene_ids) == len(set(p.gene_ids)) def test_crossover_produces_valid_child(): rng = random.Random(2) pool = [f"g{i:05d}" for i in range(50)] p1 = random_program(rng, pool) p2 = random_program(rng, pool) child = crossover(rng, p1, p2) assert child.feature_sets assert len(child.gene_ids) == len(set(child.gene_ids)) assert child.parents == [p1.program_id, p2.program_id] # --- fitness ---------------------------------------------------------------- def test_program_state_shape(): M, _ = _synthetic_matrix(n_samples=20, n_features=10) p = Program(feature_sets=[["g00001", "g00002"], ["g00005", "g00006", "g00007"]]) state = program_state(M, p) assert state.shape == (20, 2) def test_cv_auroc_strong_for_informative_program(): M, y = _synthetic_matrix(n_samples=120, n_features=50, n_informative=8) p = Program(feature_sets=[[f"g{i+1:05d}" for i in range(8)]]) score = cv_auroc(M, y, p, n_folds=5, random_state=0) assert score > 0.95 def test_fitness_penalises_size(): M, y = _synthetic_matrix(n_samples=120, n_features=50, n_informative=8) small = Program(feature_sets=[[f"g{i+1:05d}" for i in range(2)]]) big = Program(feature_sets=[[f"g{i+1:05d}" for i in range(8)]]) small_fit = fitness_fn(M, y, small, lambda_size=0.01) big_fit = fitness_fn(M, y, big, lambda_size=0.01) # both should be high; size penalty makes big at least slightly closer. assert (big_fit + 0.01 * (big.n_genes - small.n_genes)) >= small_fit - 0.05 # --- GP loop --------------------------------------------------------------- def test_run_gp_improves_or_holds_fitness_over_generations(): M, y = _synthetic_matrix(n_samples=120, n_features=80, n_informative=10) pool, _ = top_n_features(M, y, n=40) log, winner, fit = run_gp( M, y, pool, population_size=20, n_generations=8, seed=0, ) assert len(log) == 8 assert log[-1]["best_fitness"] >= log[0]["best_fitness"] - 1e-9 # --- baseline / permutation ------------------------------------------------ def test_baseline_uses_first_k(): shortlist = [f"g{i:05d}" for i in range(20)] b = make_baseline(shortlist, k=5) assert b.feature_sets == [shortlist[:5]] assert b.program_id == "baseline" def test_permutation_null_is_around_chance(): M, y = _synthetic_matrix(n_samples=120, n_features=100, n_informative=10) split = make_split(M.index, y, test_size=0.3, random_state=0) nulls = permutation_null( M.loc[split.train_ids], split.y_train, M.loc[split.test_ids], split.y_test, n_permutations=20, seed=0, ) assert 0.3 < np.mean(nulls) < 0.7 p = permutation_p_value(0.99, nulls) assert 0.0 < p <= 1.0 # --- pipeline / airgap enforcement ----------------------------------------- def test_pipeline_rejects_named_columns(): M = pd.DataFrame( {"MLH1": [1.0, 2.0, 3.0, 4.0], "TP53": [5.0, 6.0, 7.0, 8.0]}, index=["s1", "s2", "s3", "s4"], ) y = np.array([1, 0, 1, 0]) with pytest.raises(ValueError, match="opaque IDs"): run_gp_pipeline(M, y) def test_pipeline_end_to_end_on_synthetic(): M, y = _synthetic_matrix(n_samples=120, n_features=80, n_informative=10) evolution_log, result = run_gp_pipeline( M, y, seed=0, prefilter_n=30, population_size=15, n_generations=4, n_permutations=10, ) assert "winning" in result assert result["winning"]["holdout_auroc"] > 0.8 assert 0.0 < result["winning"]["permutation_p"] <= 1.0 assert len(evolution_log["generations"]) == 4 # Top-of-rank candidates carry the survived flag. cand0 = evolution_log["generations"][0]["candidates"][0] assert cand0["survived"] is True # --- objective dispatch + correlation path -------------------------------- def _synthetic_continuous( n_samples: int = 120, n_features: int = 100, n_informative: int = 8, seed: int = 0, ) -> tuple[pd.DataFrame, np.ndarray]: """Build a name-blind matrix where the first `n_informative` features NEGATIVELY track a continuous y (good test for CorrelationObjective with direction='neg').""" rng = np.random.default_rng(seed) cols = [f"g{i+1:05d}" for i in range(n_features)] sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id") y = rng.normal(loc=10.0, scale=3.0, size=n_samples) X = rng.normal(loc=5.0, scale=1.0, size=(n_samples, n_features)) # Negatively correlated informative cols: feature = -y + noise for j in range(n_informative): X[:, j] = -0.8 * y + rng.normal(scale=0.5, size=n_samples) M = pd.DataFrame(X, index=sample_ids, columns=cols) return M, y def test_correlation_objective_prefilter_finds_informative(): from engine.objectives import CorrelationObjective from engine.prefilter import top_n_features M, y = _synthetic_continuous(n_samples=150, n_features=200, n_informative=10) obj = CorrelationObjective(direction="neg") top, scores = top_n_features(M, y, n=20, objective=obj) informative = {f"g{i+1:05d}" for i in range(10)} assert len(informative & set(top)) >= 8 def test_correlation_objective_pipeline_end_to_end(): from engine.objectives import CorrelationObjective M, y = _synthetic_continuous(n_samples=180, n_features=120, n_informative=10) evolution_log, result = run_gp_pipeline( M, y, objective=CorrelationObjective(direction="neg"), seed=0, prefilter_n=40, population_size=20, n_generations=4, n_permutations=10, ) # Direction-adjusted score should be solidly positive on this signal. assert result["winning"]["holdout_score"] > 0.5 assert result["objective_spec"]["target"] == "tmb" assert result["objective_spec"]["direction"] == "neg" assert evolution_log["run"]["fitness_label"] == "|spearman|" def test_pipeline_unknown_objective_rejected_by_spec_builder(): from engine.objectives import objective_from_spec with pytest.raises(ValueError): objective_from_spec({"target": "survival", "metric": "auroc"}) def test_pipeline_no_prefilter_samples_full_pool(): """With prefilter_n=None the GP must be able to reach genes a tight prefilter would have excluded.""" from engine.gp import run_gp from engine.objectives import BinaryAUROCObjective # 60 features; the informative ones are at indices 50..59 — they would # never make a top-10 univariate prefilter because of how we build the # synthetic noise, but they MUST be reachable when prefilter is off. rng = np.random.default_rng(2) n_samples = 80 n_features = 60 cols = [f"g{i+1:05d}" for i in range(n_features)] sample_ids = pd.Index([f"s{i}" for i in range(n_samples)], name="sample_id") y = np.array([1] * 40 + [0] * 40) X = rng.normal(loc=0.0, scale=1.0, size=(n_samples, n_features)) # Mild signal in late features only — visible enough to be picked when # the GP can reach them, but no single one would dominate a prefilter. for j in range(50, 60): X[:40, j] += 0.6 M = pd.DataFrame(X, index=sample_ids, columns=cols) log_off, result_off = run_gp_pipeline( M, y, seed=0, prefilter_n=None, population_size=30, n_generations=6, n_permutations=5, ) assert log_off["run"]["prefilter_N"] is None # The GP saw the full pool (n_features) — confirmed by the n_genes # field in the run header. assert log_off["run"]["n_genes"] == n_features # And it can pick from late features; check at least one late feature # appears anywhere in the top-K candidates over the run. late = {f"g{i+1:05d}" for i in range(50, 60)} seen = {g for gen in log_off["generations"] for c in gen["candidates"] for g in c["gene_ids"]} assert late & seen, ( "GP with prefilter off never sampled any late-index feature — " "init/mutation are not drawing from the full pool" ) def test_streaming_pipeline_emits_one_event_per_generation(): from engine.pipeline import run_gp_pipeline_streaming M, y = _synthetic_matrix(n_samples=120, n_features=60, n_informative=8) received: list[dict] = [] result = run_gp_pipeline_streaming( M, y, on_generation=lambda e: received.append(e), seed=0, prefilter_n=20, population_size=12, n_generations=3, n_permutations=5, ) assert [e["generation"] for e in received] == [0, 1, 2] # Every event has the lab-shape candidates. for e in received: assert "best_fitness" in e and "median_fitness" in e assert all("survived" in c for c in e["candidates"]) # The final result is the same shape as the batch path. assert "winning" in result and "permutation_p" in result["winning"]