File size: 37,085 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
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
"""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