File size: 11,515 Bytes
0fff343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
"""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"]