Skip to content

Commit 2a866a7

Browse files
SnoopLawgclaude
andcommitted
organic-teams M5-5a: heuristic universal fitness (channel-aware)
tools/fitness/ package: score(comp, location, context) -> {fitness, kind, breakdown}. 5a heuristic works on all 12 locations; CB adapter wraps cb_sim. fitness = (damage + control) x survival_multiplier - penalties: - channel-aware multiplier stacking, NEVER crossing channels: hit channel compounds Dec-DEF x Weaken x Inc-ATK x Inc-CR/CD (only if hit/wm_gs/ bring_it_down engine present); poison = stack-count(->10 cap) x Poison-Sens (only if poison engine); hp_burn flat (cap 1) + dot_detonate, amplified by neither. - survival/control floor: penalize no survival_currency by location lethality; reward enabler when keystone_needs_enabler. - boss-script penalties from M3 boss_constraints: control zeroed via is_effect_useful (Stun/TM no-op on CB); dot_reactions (Ice Golem poison-immune -> poison zeroed; HP-Burn +10%); ACC-floor shortfall when team_acc supplied. cb_adapter defaults to cb_potential.simulate_team (deterministic), or cb_sim.evaluate_team_calibrated(use_current_gear=True) on request. 6 tests pass: CB heuristic rank-correlates with cb_sim (rho=0.77); Weaken credits hit engine not poison; Stun=0 control on CB but 0.5 on arena; no-survival penalized on CB; Ice Golem poison engine zeroed vs HP-burn. KNOWN (pre-existing, flagged): cb_sim.evaluate_team_calibrated(use_current_gear= False) imports stun_priority from cb_optimizer but it lives in cb_potential -> worked around by defaulting the adapter to cb_potential. 5b TODO: per-mode outcome sims / learned model, real ACC wiring, HH non-CB validation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent d79de7f commit 2a866a7

4 files changed

Lines changed: 813 additions & 0 deletions

File tree

tests/test_fitness.py

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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"]

tools/fitness/cb_adapter.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""CB adapter — wraps the calibrated Clan Boss simulator as a fitness source.
2+
3+
When `score()` is asked for a *simulated* fitness on the clan_boss location it
4+
delegates here. Damage (total over the fight) is the fitness; the result is
5+
clearly labelled kind="cb_sim" so callers never confuse it with the heuristic.
6+
7+
Two underlying sims exist (CLAUDE.md):
8+
• cb_potential.simulate_team — builds 6★/booked/optimal gear for ANY hero;
9+
deterministic, fast (~0.1–0.8s), works without owned-gear data. DEFAULT.
10+
• cb_sim.evaluate_team_calibrated(use_current_gear=True) — uses each hero's
11+
real equipped artifacts; matches the +0.61% calibration but only works for
12+
owned/geared heroes. Used when context['use_current_gear'] is True.
13+
14+
NOTE: the sim under-survives vs the real game (see memory
15+
project_cb_sim_calibration_state) — treat the number as an estimate / ranking
16+
signal, exactly how cb_recommender labels it.
17+
"""
18+
from __future__ import annotations
19+
20+
import sys
21+
from pathlib import Path
22+
23+
_TOOLS = Path(__file__).resolve().parent.parent
24+
if str(_TOOLS) not in sys.path:
25+
sys.path.insert(0, str(_TOOLS))
26+
27+
28+
def cb_sim_score(comp: list[str], context: dict) -> dict:
29+
"""Run the CB sim on `comp` and return {fitness, kind:"cb_sim", breakdown}."""
30+
context = context or {}
31+
cb_element = int(context.get("cb_element", 4)) # default Void
32+
use_current_gear = bool(context.get("use_current_gear", False))
33+
34+
try:
35+
if use_current_gear:
36+
import cb_sim
37+
r = cb_sim.evaluate_team_calibrated(
38+
list(comp), cb_element=cb_element, use_current_gear=True,
39+
deterministic=True)
40+
source = "cb_sim.evaluate_team_calibrated(use_current_gear=True)"
41+
else:
42+
import cb_potential
43+
r = cb_potential.simulate_team(list(comp), cb_element=cb_element)
44+
source = "cb_potential.simulate_team"
45+
except Exception as e: # surfacing > swallowing: report, fall back to 0
46+
return {
47+
"fitness": 0.0, "kind": "cb_sim",
48+
"breakdown": {"error": f"{type(e).__name__}: {e}",
49+
"source": "cb_adapter", "comp": list(comp)},
50+
}
51+
52+
if r.get("error"):
53+
return {"fitness": 0.0, "kind": "cb_sim",
54+
"breakdown": {"error": r["error"], "source": source,
55+
"comp": list(comp)}}
56+
57+
total = float(r.get("total", 0.0) or 0.0)
58+
return {
59+
"fitness": total,
60+
"kind": "cb_sim",
61+
"breakdown": {
62+
"total": total,
63+
"cb_turns": r.get("cb_turns"),
64+
"valid": r.get("valid"),
65+
"cb_element": cb_element,
66+
"source": source,
67+
"comp": list(comp),
68+
"estimate_note": ("CB sim under-survives vs real game; treat as a "
69+
"ranking estimate, not an absolute clear check."),
70+
},
71+
}

0 commit comments

Comments
 (0)