|
| 1 | +"""M5 PHASE 5a — universal fitness function acceptance tests. |
| 2 | +
|
| 3 | +Run: python -m pytest tests/test_fitness.py -q |
| 4 | +
|
| 5 | +Covers the five acceptance criteria from the milestone: |
| 6 | + 1. CB heuristic RANK-correlates with cb_sim on strong↔weak comps. |
| 7 | + 2. Channel rule: a Weaken amp credits a HIT engine, never a POISON engine. |
| 8 | + 3. Boss-script: a Stun comp = ~0 control value on clan_boss, positive on arena. |
| 9 | + 4. Survival floor: no survival_currency is penalized on a lethal location. |
| 10 | + 5. Ice Golem: a poison engine is penalized vs a non-poison engine (poison-immune). |
| 11 | +""" |
| 12 | +from __future__ import annotations |
| 13 | + |
| 14 | +import os |
| 15 | +import sys |
| 16 | + |
| 17 | +import pytest |
| 18 | + |
| 19 | +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) |
| 20 | +TOOLS = os.path.join(ROOT, "tools") |
| 21 | +for p in (ROOT, TOOLS): |
| 22 | + if p not in sys.path: |
| 23 | + sys.path.insert(0, p) |
| 24 | + |
| 25 | +from fitness import score # noqa: E402 |
| 26 | + |
| 27 | + |
| 28 | +# --------------------------------------------------------------------------- # |
| 29 | +# Synthetic M1 records — pure tag predicates, no real-roster dependency, so |
| 30 | +# the channel/boss/survival rules are tested in isolation. |
| 31 | +# --------------------------------------------------------------------------- # |
| 32 | +def _rec(name, **kw): |
| 33 | + base = dict(name=name, provides=[], needs=[], amplifier_channel="none", |
| 34 | + engine_channel=[], survival_currency=None, enabler=None, |
| 35 | + keystone_needs_enabler=False) |
| 36 | + base.update(kw) |
| 37 | + return base |
| 38 | + |
| 39 | + |
| 40 | +WEAKEN_AMP = _rec("WeakenGuy", provides=["enemy_debuff:Weaken"], |
| 41 | + amplifier_channel="hit") |
| 42 | +HIT_ENGINE = _rec("HitDPS", engine_channel=["hit"]) |
| 43 | +POISON_ENGINE = _rec("PoisonDPS", provides=["dot:Poison"], |
| 44 | + engine_channel=["poison"]) |
| 45 | +HP_BURN_ENGINE = _rec("Burner", provides=["dot:HP Burn"], |
| 46 | + engine_channel=["hp_burn"]) |
| 47 | +STUNNER = _rec("Stunner", provides=["enemy_debuff:Stun"]) |
| 48 | +TANK = _rec("Tank", survival_currency="unkillable") |
| 49 | + |
| 50 | + |
| 51 | +def _ov(*recs): |
| 52 | + return {"records_override": {r["name"]: r for r in recs}} |
| 53 | + |
| 54 | + |
| 55 | +# --------------------------------------------------------------------------- # |
| 56 | +# 1. CB heuristic rank-correlates with cb_sim. |
| 57 | +# --------------------------------------------------------------------------- # |
| 58 | +def _spearman(a, b): |
| 59 | + def rank(x): |
| 60 | + order = sorted(range(len(x)), key=lambda i: x[i]) |
| 61 | + rk = [0] * len(x) |
| 62 | + for pos, i in enumerate(order): |
| 63 | + rk[i] = pos |
| 64 | + return rk |
| 65 | + ra, rb = rank(a), rank(b) |
| 66 | + n = len(a) |
| 67 | + d2 = sum((ra[i] - rb[i]) ** 2 for i in range(n)) |
| 68 | + return 1 - 6 * d2 / (n * (n * n - 1)) |
| 69 | + |
| 70 | + |
| 71 | +# Comps spanning strong (survival + amps + many engines) → weak (no survival / |
| 72 | +# few engines). All heroes exist in the roster so cb_potential can gear them. |
| 73 | +_CB_COMPS = { |
| 74 | + "MEN_full": ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"], |
| 75 | + "one_surv_dps": ["Demytha", "Ninja", "Geomancer", "Venomage", "Frozen Banshee"], |
| 76 | + "cardiel_men": ["Maneater", "Cardiel", "Ninja", "Geomancer", "Venomage"], |
| 77 | + "MEN_noVenom": ["Maneater", "Demytha", "Ninja", "Geomancer", "Pain Keeper"], |
| 78 | + "no_surv_all_dps": ["Ninja", "Geomancer", "Venomage", "Frozen Banshee", "Hyria"], |
| 79 | + "painkeeper_dps": ["Pain Keeper", "Ninja", "Geomancer", "Venomage", "Frozen Banshee"], |
| 80 | +} |
| 81 | + |
| 82 | + |
| 83 | +def test_cb_heuristic_rank_correlates_with_sim(): |
| 84 | + try: |
| 85 | + import cb_potential |
| 86 | + except Exception as e: # pragma: no cover |
| 87 | + pytest.skip(f"cb_potential unavailable: {e}") |
| 88 | + |
| 89 | + sim_vals, heur_vals, labels = [], [], [] |
| 90 | + for label, team in _CB_COMPS.items(): |
| 91 | + sim = cb_potential.simulate_team(team, cb_element=1) |
| 92 | + if sim.get("error"): |
| 93 | + pytest.skip(f"sim error on {label}: {sim['error']}") |
| 94 | + heur = score(team, "clan_boss", {"cb_element": 1}) |
| 95 | + assert heur["kind"] == "heuristic" |
| 96 | + sim_vals.append(sim["total"]) |
| 97 | + heur_vals.append(heur["fitness"]) |
| 98 | + labels.append(label) |
| 99 | + |
| 100 | + rho = _spearman(sim_vals, heur_vals) |
| 101 | + assert rho >= 0.5, f"weak rank correlation rho={rho:.3f}\n" + \ |
| 102 | + "\n".join(f" {l}: sim={s:.0f} heur={h:.3f}" |
| 103 | + for l, s, h in zip(labels, sim_vals, heur_vals)) |
| 104 | + |
| 105 | + # Explicit sanity: full survival comp >> no-survival all-DPS comp. |
| 106 | + men = score(_CB_COMPS["MEN_full"], "clan_boss", {"cb_element": 1})["fitness"] |
| 107 | + allin = score(_CB_COMPS["no_surv_all_dps"], "clan_boss", |
| 108 | + {"cb_element": 1})["fitness"] |
| 109 | + assert men > allin |
| 110 | + |
| 111 | + |
| 112 | +# --------------------------------------------------------------------------- # |
| 113 | +# 2. Channel rule: Weaken credits a hit engine, never a poison engine. |
| 114 | +# --------------------------------------------------------------------------- # |
| 115 | +def test_channel_weaken_credits_hit_not_poison(): |
| 116 | + ov = _ov(WEAKEN_AMP, HIT_ENGINE, POISON_ENGINE, TANK) |
| 117 | + |
| 118 | + hit_no = score(["HitDPS", "Tank"], "clan_boss", ov) |
| 119 | + hit_yes = score(["HitDPS", "WeakenGuy", "Tank"], "clan_boss", ov) |
| 120 | + # Hit engine: Weaken raises the amplifier multiplier and the fitness. |
| 121 | + assert hit_yes["breakdown"]["channels"]["hit"]["amplifier_multiplier"] > \ |
| 122 | + hit_no["breakdown"]["channels"]["hit"]["amplifier_multiplier"] |
| 123 | + assert hit_yes["fitness"] > hit_no["fitness"] |
| 124 | + |
| 125 | + poi_no = score(["PoisonDPS", "Tank"], "clan_boss", ov) |
| 126 | + poi_yes = score(["PoisonDPS", "WeakenGuy", "Tank"], "clan_boss", ov) |
| 127 | + # Poison engine: a hit-channel Weaken amp must give NO credit. |
| 128 | + assert poi_yes["breakdown"]["channels"]["poison"]["score"] == \ |
| 129 | + poi_no["breakdown"]["channels"]["poison"]["score"] |
| 130 | + assert poi_yes["fitness"] == poi_no["fitness"] |
| 131 | + |
| 132 | + |
| 133 | +# --------------------------------------------------------------------------- # |
| 134 | +# 3. Boss-script: Stun control is zero-value on CB, positive on arena. |
| 135 | +# --------------------------------------------------------------------------- # |
| 136 | +def test_stun_control_zeroed_on_cb_but_useful_on_arena(): |
| 137 | + ov = _ov(STUNNER, TANK) |
| 138 | + cb = score(["Stunner", "Tank"], "clan_boss", ov)["breakdown"]["control"] |
| 139 | + arena = score(["Stunner", "Tank"], "arena", ov)["breakdown"]["control"] |
| 140 | + |
| 141 | + assert cb["score"] == 0.0 |
| 142 | + assert "stun" in cb["no_op_vs_boss"] |
| 143 | + assert arena["score"] > 0.0 |
| 144 | + assert "stun" in arena["useful"] |
| 145 | + |
| 146 | + |
| 147 | +# --------------------------------------------------------------------------- # |
| 148 | +# 4. Survival floor: no survival_currency is penalized on a lethal location. |
| 149 | +# --------------------------------------------------------------------------- # |
| 150 | +def test_survival_floor_penalizes_no_survival_on_lethal_cb(): |
| 151 | + ov = _ov(HIT_ENGINE, TANK) |
| 152 | + no_surv = score(["HitDPS", "HitDPS"], "clan_boss", ov) |
| 153 | + with_surv = score(["HitDPS", "Tank"], "clan_boss", ov) |
| 154 | + |
| 155 | + assert with_surv["fitness"] > no_surv["fitness"] |
| 156 | + # The penalty is the multiplicative survival floor on a lethal location. |
| 157 | + assert no_surv["breakdown"]["survival"]["multiplier"] < \ |
| 158 | + with_surv["breakdown"]["survival"]["multiplier"] |
| 159 | + |
| 160 | + |
| 161 | +# --------------------------------------------------------------------------- # |
| 162 | +# 5. Ice Golem: a poison engine is penalized vs a non-poison engine. |
| 163 | +# --------------------------------------------------------------------------- # |
| 164 | +def test_ice_golem_penalizes_poison_engine(): |
| 165 | + ov = _ov(POISON_ENGINE, HP_BURN_ENGINE, TANK) |
| 166 | + poison = score(["PoisonDPS", "Tank"], "ice_golem", ov) |
| 167 | + burn = score(["Burner", "Tank"], "ice_golem", ov) |
| 168 | + |
| 169 | + assert burn["fitness"] > poison["fitness"] |
| 170 | + pch = poison["breakdown"]["channels"]["poison"] |
| 171 | + assert pch["boss_poison_immune"] is True |
| 172 | + assert pch["score"] == 0.0 # poison damage zeroed vs an immune boss |
| 173 | + |
| 174 | + |
| 175 | +# --------------------------------------------------------------------------- # |
| 176 | +# CB adapter labels its kind correctly (heuristic vs simulated). |
| 177 | +# --------------------------------------------------------------------------- # |
| 178 | +def test_cb_adapter_labels_kind(): |
| 179 | + team = _CB_COMPS["MEN_full"] |
| 180 | + heur = score(team, "clan_boss", {"cb_element": 1}) |
| 181 | + assert heur["kind"] == "heuristic" |
| 182 | + |
| 183 | + try: |
| 184 | + import cb_potential # noqa: F401 |
| 185 | + except Exception as e: # pragma: no cover |
| 186 | + pytest.skip(f"cb_potential unavailable: {e}") |
| 187 | + sim = score(team, "clan_boss", {"cb_element": 1, "sim": True}) |
| 188 | + assert sim["kind"] == "cb_sim" |
| 189 | + assert sim["fitness"] > 0 |
| 190 | + assert sim["breakdown"]["source"] |
0 commit comments