File size: 11,538 Bytes
2fc729c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e4a8f7
 
 
 
 
 
 
 
 
 
2fc729c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Offline eval harness for the job evolution agent.

No API key, no network.  Verifies:
  - Case library integrity (sources, variable bounds, outcome coverage)
  - Factor analysis: explained variance, loading signs, determinism
  - Cluster stability: bootstrap ARI above floor
  - OOD detector: calibration, known-inside vs known-outside discrimination
  - Conditional rules: direction correctness vs economic theory priors
  - EvolutionPrior: serialises cleanly, prompt context contains OOD warning

Run:  python tests/test_evolution.py
"""
import sys
from pathlib import Path

_project_root = str(Path(__file__).resolve().parent.parent)
if _project_root not in sys.path:
    sys.path.insert(0, _project_root)

import numpy as np

from evolution import (
    CASE_LIBRARY, CURRENT_AI_SCENARIO, VARIABLE_NAMES,
    PCAReducer, BayesianGMMClusterer, OODDetector,
    build_prior, extract_conditional_rules, _normalise_diffusion,
    TransitionCase,
)


# โ”€โ”€ helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def _build(n_boot=10):
    """Fast prior build for tests (fewer bootstrap iterations)."""
    return build_prior(n_bootstrap=n_boot)


# โ”€โ”€ case library โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def test_case_library_integrity():
    assert len(CASE_LIBRARY) >= 10, "need at least 10 cases"
    for c in CASE_LIBRARY:
        for vname in VARIABLE_NAMES:
            v = getattr(c, vname)
            if vname == "diffusion_years":
                assert v > 0, f"{c.id}: diffusion_years must be positive"
            else:
                assert 0.0 <= v <= 1.0, f"{c.id}.{vname}={v} outside [0,1]"
        assert len(c.sources) >= 1, f"{c.id}: must have โ‰ฅ1 source"
        assert c.net_job_multiplier > 0, f"{c.id}: multiplier must be positive"
        assert c.lag_years >= 0, f"{c.id}: lag_years must be non-negative"
    net_gain = sum(1 for c in CASE_LIBRARY if c.net_job_multiplier > 1.0)
    net_loss  = len(CASE_LIBRARY) - net_gain
    assert net_gain >= 2 and net_loss >= 2, "library needs both net-gain and net-loss cases"
    print(f"ok  case library integrity ({len(CASE_LIBRARY)} cases, "
          f"{net_gain} net-gain, {net_loss} net-loss)")


# โ”€โ”€ factor analysis โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def test_factor_explained_variance():
    X = _normalise_diffusion(CASE_LIBRARY)
    r = PCAReducer(n_components=3).fit(X)
    evr = r.explained_variance_ratio()
    assert sum(evr) >= 0.55, f"3 factors should explain โ‰ฅ55% variance, got {sum(evr):.2%}"
    print(f"ok  PCA explained variance: {[f'{v:.2%}' for v in evr]} "
          f"(total {sum(evr):.2%})")


def test_factor_loading_signs():
    """Augmentation_ratio and task_frontier_open should load together on
    the dominant 'complementarity' factor (same sign, high magnitude)."""
    X = _normalise_diffusion(CASE_LIBRARY)
    r = PCAReducer(n_components=3).fit(X)
    comps = r.components()  # (3, 8)
    aug_idx = VARIABLE_NAMES.index("augmentation_ratio")
    frontier_idx = VARIABLE_NAMES.index("task_frontier_open")
    # dominant factor
    f0 = comps[0]
    aug_load, frontier_load = f0[aug_idx], f0[frontier_idx]
    assert aug_load * frontier_load > 0, (
        "augmentation_ratio and task_frontier_open should load with same sign "
        f"on PC1 (got {aug_load:.3f}, {frontier_load:.3f})")
    print(f"ok  factor loading signs: augmentation_ratio={aug_load:.3f}, "
          f"task_frontier_open={frontier_load:.3f} (same sign on PC1)")


def test_pca_determinism():
    X = _normalise_diffusion(CASE_LIBRARY)
    a = PCAReducer(n_components=3).fit(X).transform(X)
    b = PCAReducer(n_components=3).fit(X).transform(X)
    assert np.allclose(np.abs(a), np.abs(b), atol=1e-8), \
        "PCA must be deterministic (sign flips OK, magnitudes must match)"
    print("ok  PCA determinism (sign-invariant)")


# โ”€โ”€ clustering โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def test_cluster_count_reasonable():
    X = _normalise_diffusion(CASE_LIBRARY)
    r = PCAReducer(n_components=3).fit(X)
    Z = r.transform(X)
    cl = BayesianGMMClusterer(max_components=5).fit(Z)
    labels = cl.predict(Z)
    n_clusters = len(set(labels))
    assert 2 <= n_clusters <= 5, f"expected 2-5 clusters, got {n_clusters}"
    print(f"ok  Bayesian GMM found {n_clusters} clusters (DP prior working)")


def test_net_gain_loss_separation():
    """Clusters should not be random w.r.t. outcome: the mean net_job_multiplier
    should differ meaningfully across clusters."""
    X = _normalise_diffusion(CASE_LIBRARY)
    r = PCAReducer(n_components=3).fit(X)
    Z = r.transform(X)
    cl = BayesianGMMClusterer(max_components=5).fit(Z)
    labels = cl.predict(Z)
    per_cluster = {}
    for c, lbl in zip(CASE_LIBRARY, labels):
        per_cluster.setdefault(lbl, []).append(c.net_job_multiplier)
    means = [sum(v) / len(v) for v in per_cluster.values()]
    spread = max(means) - min(means)
    assert spread >= 0.3, (
        f"clusters should differ in mean net_job_multiplier by โ‰ฅ0.3, got {spread:.3f}")
    print(f"ok  cluster outcome spread: max-min multiplier = {spread:.3f}")


def test_bootstrap_stability_above_floor():
    prior = _build(n_boot=20)
    assert prior.bootstrap_stability >= 0.2, (
        f"bootstrap ARI {prior.bootstrap_stability:.3f} below floor 0.2 โ€” "
        "clusters are too unstable to trust")
    print(f"ok  bootstrap stability ARI = {prior.bootstrap_stability:.3f}")


# โ”€โ”€ OOD detector โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def test_ood_training_points_inside():
    """All training cases should be inside the historical envelope."""
    X = _normalise_diffusion(CASE_LIBRARY)
    r = PCAReducer(n_components=3).fit(X)
    Z = r.transform(X)
    cl = BayesianGMMClusterer(max_components=5).fit(Z)
    labels = cl.predict(Z)
    det = OODDetector(threshold_percentile=90).fit(Z, labels)
    # At 90th pct threshold, at most 10% of training points should fire OOD
    ood_flags = [det.score(Z[i])["is_ood"] for i in range(len(Z))]
    ood_rate = sum(ood_flags) / len(ood_flags)
    assert ood_rate <= 0.15, f"OOD rate on training data = {ood_rate:.0%} (expect โ‰ค15%)"
    print(f"ok  OOD training-set rate = {ood_rate:.0%} (โ‰ค15% threshold)")


def test_ood_extreme_scenario_fires():
    """A scenario far outside all historical cases should fire the OOD flag."""
    extreme = {v: 0.99 for v in VARIABLE_NAMES}   # all maxed out โ€” unprecedented
    import math
    X = _normalise_diffusion(CASE_LIBRARY)
    r = PCAReducer(n_components=3).fit(X)
    Z = r.transform(X)
    cl = BayesianGMMClusterer(max_components=5).fit(Z)
    labels = cl.predict(Z)
    det = OODDetector(threshold_percentile=90).fit(Z, labels)
    extreme_vec = np.array([
        extreme[v] if v != "diffusion_years"
        else math.log1p(extreme[v]) / math.log1p(50)
        for v in VARIABLE_NAMES
    ]).reshape(1, -1)
    z_ext = r.transform(extreme_vec)[0]
    score = det.score(z_ext)
    assert score["is_ood"], f"extreme scenario should fire OOD: {score}"
    print(f"ok  extreme scenario fires OOD (ratio={score['ood_ratio']:.2f})")


# โ”€โ”€ conditional rules โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def test_conditional_rules_directions():
    import re as _re
    rules = extract_conditional_rules(CASE_LIBRARY)

    # augmentation_ratio multiplier rule
    aug_rule = next((r for r in rules if "augmentation_ratio" in r and "multiplier" in r), None)
    assert aug_rule is not None, "augmentation_ratio multiplier rule missing"
    m = _re.search(r"multiplier\s+([\d.]+)\s+vs\s+([\d.]+)", aug_rule)
    assert m, f"could not parse multiplier values from: {aug_rule}"
    high_m, low_m = float(m.group(1)), float(m.group(2))
    assert high_m > low_m, (
        f"high augmentation should -> higher multiplier: {high_m:.2f} vs {low_m:.2f}")

    # net-gain discriminator rule: net-gain cases should have higher aug ratio
    disc_rule = next((r for r in rules if "strongest discriminator" in r), None)
    assert disc_rule is not None, "discriminator rule missing"
    dm = _re.search(r"augmentation_ratio=([\d.]+).*?mean=([\d.]+)", disc_rule)
    assert dm, f"could not parse discriminator rule: {disc_rule}"
    gain_aug, loss_aug = float(dm.group(1)), float(dm.group(2))
    assert gain_aug > loss_aug, (
        f"net-gain should have higher aug_ratio: {gain_aug:.2f} vs {loss_aug:.2f}")

    assert any("CAUTION" in r for r in rules)
    print(f"ok  conditional rules: high_aug multiplier {high_m:.2f} > low {low_m:.2f}; "
          f"net-gain aug_ratio {gain_aug:.2f} > net-loss {loss_aug:.2f}")


# โ”€โ”€ end-to-end prior + prompt โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def test_prior_builds_and_serialises():
    prior = _build(n_boot=10)
    assert prior.clusters, "should have at least one cluster"
    assert prior.nearest_cluster is not None
    assert 0.0 <= prior.bootstrap_stability <= 1.0
    assert len(prior.factor_loadings) == prior.n_factors * len(VARIABLE_NAMES)
    prompt = prior.to_prompt_context()
    assert "OOD signal" in prompt
    assert "Nearest historical regime" in prompt
    assert "CAUTION" in prompt
    print(f"ok  prior builds and serialises: {len(prior.clusters)} clusters, "
          f"prompt={len(prompt)} chars")


def test_cluster_names_are_distinct_and_english():
    prior = _build(n_boot=10)
    names = [c.name for c in prior.clusters]
    assert len(names) == len(set(names)), f"duplicate cluster names: {names}"
    for name in names:
        assert "ยท" in name, f"expected structured label, got {name!r}"
        assert not any("\u4e00" <= ch <= "\u9fff" for ch in name), name
    print(f"ok  cluster names distinct: {names}")


def test_ood_warning_in_prompt_when_ood():
    """If the current scenario is OOD, the prompt context must say so explicitly."""
    prior = _build(n_boot=10)
    prompt = prior.to_prompt_context()
    if prior.current_scenario_ood["is_ood"]:
        assert "widen confidence intervals" in prompt
        print("ok  OOD warning appears in prompt (scenario is OOD)")
    else:
        assert "within historical envelope" in prompt
        print("ok  OOD not fired for current scenario (within envelope)")


# โ”€โ”€ main โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€

def main():
    fns = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
    for fn in fns:
        fn()
    print(f"\nALL {len(fns)} EVOLUTION AGENT TESTS PASSED")


if __name__ == "__main__":
    main()