Skip to content

Commit f52f7ec

Browse files
SnoopLawgclaude
andcommitted
organic-teams M4: generative assembly engine (replaces template-matching)
tools/team_generator.py: generate(location, roster, GenOpts) -> [CandidateComp], driven by ABSTRACT tag-predicate archetype skeletons (zero champion names). The unified skeleton (survival/enabler/amplifier:<engine_channel>/engine:<channel>/ flex) is instantiated once per engine channel in the roster. Pipeline: M3 hard filters -> stratified CSP (MRV + channel-anchor enumeration + beam core-fill + channel-aware skeleton_prescore, predict_score tie-break) -> M2 resolve (drop unmet hard edges) -> team-rule recheck -> M5 fitness rank (rank_with=auto: two- phase cb_sim for CB, heuristic elsewhere) -> novelty flag vs DWJ. Supersedes the template-matching path (comp_finder DWJ-scoring / m5_recommender greedy coverage): teams are derived from M1 game-truth tags via skeletons; scraped templates demoted to the novelty cross-check. HONEST FINDING: cb_sim does NOT rank the user's known comp #1 (it over-rates stall comps ~21-27M vs the real 15-18M — the documented cb_sim under-survival bias, memory project_cb_sim_calibration_state). The comp IS rediscovered organically and lands in top-K (rank ~26, unified[hit]); only cb_sim distinguishes the survival keystones and it mid-ranks the proven comp. Treated cb_sim as authoritative-but-imperfect oracle per spec, did NOT fit the score to force rank 1. 9 tests pass (~124s): reproduces user comp in top-K + organic proof (source scan asserts no champion-name literals); skeleton team-rules; FW single-faction; CB no CC/TM value; poison engine never credited Weaken; pool=all <60s deterministic + no-engine edge case. Remaining (Wave 3): non-CB ranks on M5 heuristic only (need 5b per-mode sims); prescore is CB-stall-shaped (other bosses want location-specific weighting). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent 2a866a7 commit f52f7ec

2 files changed

Lines changed: 1364 additions & 0 deletions

File tree

tests/test_team_generator.py

Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
1+
"""Acceptance tests for M4 — tools/team_generator.py (generative assembly).
2+
3+
Maps 1:1 onto the spec's acceptance list (docs/organic_team_m2_m4_spec.md "M4"):
4+
5+
1. Reproduce the user's known CB comp in top-K from the *unified* skeleton,
6+
AND prove the skeletons/search contain ONLY tag predicates (no champion
7+
names) — the organic proof.
8+
2. Every emitted CandidateComp independently passes its skeleton team rules.
9+
3. Faction Wars -> every comp is single-faction.
10+
4. Clan Boss -> no comp's value derives from a CC/TM effect.
11+
5. Channel consistency -> a poison-engine comp never credits a Weaken/Dec-DEF
12+
amplifier as its damage multiplier (asserted via M2 resolve).
13+
6. Tractability -> pool="all" completes under a wall-clock budget, respects
14+
max_candidates, and a fixed seed is deterministic.
15+
16+
Test 1 runs the real cb_sim (rank_with="auto") and is the slow one (~1-2 min);
17+
the rest use the fast heuristic ranker. Everything reads the live repo data
18+
(heroes_6star.json + data/m5_synergy.jsonl + boss_constraints), exactly like
19+
cb_team_explorer, so no fixtures are needed.
20+
"""
21+
import re
22+
import sys
23+
import time
24+
from pathlib import Path
25+
26+
import pytest
27+
28+
ROOT = Path(__file__).resolve().parent.parent
29+
TOOLS = ROOT / "tools"
30+
for p in (str(ROOT), str(TOOLS)):
31+
if p not in sys.path:
32+
sys.path.insert(0, p)
33+
34+
import team_generator as tg # noqa: E402
35+
import synergy_resolver as sr # noqa: E402
36+
37+
# The user's known-good CB comp (used ONLY by the test as the rediscovery
38+
# target — it appears nowhere in team_generator's skeletons/search).
39+
TARGET = frozenset({"Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"})
40+
CHAMPION_NAMES = sorted(TARGET) + ["Ma'Shalled", "Skullcrusher", "Arbiter",
41+
"Cardiel", "Fayne", "Teodor the Savant"]
42+
43+
# Allowed slot-predicate vocabulary (spec §M4 "Predicate keys").
44+
_PRED_RE = re.compile(
45+
r"^(survival:[a-z_]+|enabler:[a-z_]+|engine:[a-z_]+|amplifier:[a-z_]+|"
46+
r"cleanse|heal|tm_control|dot_detonate|acc_capable)$")
47+
48+
49+
# --------------------------------------------------------------------------- #
50+
# shared fixtures
51+
# --------------------------------------------------------------------------- #
52+
@pytest.fixture(scope="module")
53+
def owned():
54+
return tg.load_owned_roster()
55+
56+
57+
def _base_set(comp):
58+
return frozenset(tg._base_name(n) for n in comp.team)
59+
60+
61+
# --------------------------------------------------------------------------- #
62+
# 1. Reproduce the user's comp + organic proof
63+
# --------------------------------------------------------------------------- #
64+
def test_skeletons_and_search_are_tag_predicates_only():
65+
"""Organic proof: the skeletons/search reference ONLY abstract tag
66+
predicates — zero champion names anywhere in the assembly logic."""
67+
# (a) every predicate in every channel skeleton parses to the vocabulary
68+
# and contains no champion name.
69+
for ch in tg.ENGINE_CHANNELS:
70+
sk = tg.build_unified_skeleton(ch)
71+
assert sk.name.startswith("unified")
72+
for slot in sk.slots:
73+
assert slot.require_any, slot
74+
for pred in slot.require_any:
75+
assert _PRED_RE.match(pred), f"non-vocab predicate {pred!r}"
76+
for champ in CHAMPION_NAMES:
77+
assert champ.lower() not in pred.lower()
78+
79+
# (b) the module source itself contains no champion-name string literals
80+
# (the strongest form of the proof).
81+
src = (TOOLS / "team_generator.py").read_text(encoding="utf-8")
82+
for champ in CHAMPION_NAMES:
83+
assert champ not in src, f"champion name {champ!r} leaked into source"
84+
85+
86+
def test_reproduce_user_cb_comp_from_unified_skeleton(owned):
87+
"""generate(clan_boss, owned, rank_with='auto') rediscovers the user's
88+
Maneater/Demytha/Ninja/Geomancer/Venomage comp in the top-K output,
89+
instantiated from the unified skeleton — with no champion-name templates."""
90+
res = tg.generate("clan_boss", owned, tg.GenOpts(rank_with="auto", top=40))
91+
92+
hit = [(i, c) for i, c in enumerate(res, 1) if _base_set(c) == TARGET]
93+
assert hit, ("user's known CB comp was not rediscovered in the top-K; "
94+
f"top teams: {[sorted(c.team) for c in res[:5]]}")
95+
rank, comp = hit[0]
96+
# rediscovered organically from the unified skeleton:
97+
assert comp.skeleton.startswith("unified"), comp.skeleton
98+
# it was actually evaluated by the CB simulator (auto -> cb_sim on CB):
99+
assert comp.fitness_kind == "cb_sim"
100+
assert comp.fitness > 0
101+
# and it is role-valid (resolve has no broken keystone-enabler edge):
102+
for k in comp.resolve.keystones:
103+
if k.get("needs_enabler"):
104+
assert k.get("enabler_ok"), k
105+
106+
107+
# --------------------------------------------------------------------------- #
108+
# 2. Every emitted comp passes its skeleton's team rules
109+
# --------------------------------------------------------------------------- #
110+
def test_every_emitted_comp_passes_team_rules(owned):
111+
res = tg.generate("clan_boss", owned, tg.GenOpts(rank_with="heuristic",
112+
top=40))
113+
assert len(res) > 0
114+
for c in res:
115+
ch = c.skeleton[c.skeleton.index("[") + 1:c.skeleton.rindex("]")]
116+
sk = tg.build_unified_skeleton(ch)
117+
recs = [r for r in (tg.record_for(n) for n in c.team) if r is not None]
118+
ok, rep = tg.team_rules_report(recs, sk, "clan_boss", is_cb=True)
119+
assert ok, (sorted(c.team), rep)
120+
# spelled-out rules from the spec:
121+
assert rep["survival_present"]
122+
assert rep["keystone_ok"] # enabler_if_keystone
123+
assert rep["channel_consistent"]
124+
assert rep["acc_floor_met"]
125+
126+
127+
# --------------------------------------------------------------------------- #
128+
# 3. Faction Wars -> single faction
129+
# --------------------------------------------------------------------------- #
130+
def test_faction_wars_comps_are_single_faction():
131+
# pool="all" so factions have enough heroes to field the archetype.
132+
res = tg.generate("faction_wars", None,
133+
tg.GenOpts(pool="all", rank_with="heuristic", top=20,
134+
max_candidates=1500))
135+
assert len(res) > 0, "no FW comps generated"
136+
for c in res:
137+
fr = {tg.record_for(n).get("fraction") for n in c.team}
138+
fr.discard(None)
139+
assert len(fr) == 1, (sorted(c.team), fr)
140+
assert c.constraint_report["faction_ok"]
141+
142+
143+
# --------------------------------------------------------------------------- #
144+
# 4. Clan Boss -> no value from CC/TM
145+
# --------------------------------------------------------------------------- #
146+
def test_cb_no_value_from_cc_or_tm(owned):
147+
res = tg.generate("clan_boss", owned, tg.GenOpts(rank_with="heuristic",
148+
top=40))
149+
assert len(res) > 0
150+
for c in res:
151+
# (a) M2 resolve never credits a TM/CC need as satisfied on CB.
152+
for e in c.resolve.satisfied:
153+
assert e.tag != "tm_control", (sorted(c.team), str(e))
154+
# (b) the heuristic fitness breakdown counts ZERO useful control value
155+
# (the CB boss no-ops every control/TM effect).
156+
bd = c.constraint_report.get("fitness_breakdown", {})
157+
control = bd.get("control", {})
158+
assert control.get("useful", []) == [], (sorted(c.team), control)
159+
assert control.get("score", 0) == 0
160+
161+
162+
# --------------------------------------------------------------------------- #
163+
# 5. Channel consistency — poison engine never credits a Weaken amplifier
164+
# --------------------------------------------------------------------------- #
165+
def test_poison_engine_does_not_credit_weaken_amplifier(owned):
166+
"""A poison engine carrying needs:def_break (m5_synergy_graph adds it to all
167+
attackers) must NOT be credited a Dec-DEF/Weaken edge — that amplifier is a
168+
HIT-channel multiplier, not a poison one. Asserted via M2 resolve."""
169+
res = tg.generate("clan_boss", owned,
170+
tg.GenOpts(rank_with="heuristic", top=60))
171+
poison_comps = [c for c in res if c.skeleton == "unified[poison]"]
172+
assert poison_comps, "no poison-engine comps generated"
173+
174+
checked = 0
175+
for c in poison_comps:
176+
recs = {r["name"]: r for r in
177+
(tg.record_for(n) for n in c.team) if r is not None}
178+
poison_engines = {n for n, r in recs.items()
179+
if "poison" in (r.get("engine_channel") or [])
180+
and not (set(r.get("engine_channel") or [])
181+
& {"hit", "wm_gs", "bring_it_down"})}
182+
if not poison_engines:
183+
continue
184+
checked += 1
185+
# No satisfied def_break (hit-channel) edge may target a pure-poison
186+
# engine — the channel gate must have rejected it.
187+
for e in c.resolve.satisfied:
188+
if e.tag == "def_break":
189+
assert e.consumer not in poison_engines, (
190+
sorted(c.team), str(e),
191+
"Weaken/Dec-DEF wrongly credited to a poison engine")
192+
assert e.channel == "hit"
193+
assert checked > 0, "no pure-poison engine appeared to verify the gate"
194+
195+
196+
def test_resolve_channel_gate_rejects_weaken_to_poison_directly():
197+
"""Direct M2 check (no search): a hand-built comp with a hit amplifier and
198+
a pure-poison engine must route def_break to the hit engine, never the
199+
poison engine."""
200+
comp = ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"]
201+
rr = sr.resolve(comp, sr.ResolveContext(location="clan_boss"))
202+
# Venomage is the pure-poison engine; it must not be a def_break consumer.
203+
for e in rr.satisfied:
204+
if e.tag == "def_break":
205+
assert e.consumer != "Venomage", str(e)
206+
# and the channel-mismatch shows up as a note, not satisfied/broken.
207+
assert any("Venomage" in n and "poison_synergy" in n
208+
and "channel mismatch" in n for n in rr.notes), rr.notes
209+
210+
211+
# --------------------------------------------------------------------------- #
212+
# 6. Tractability — pool=all under budget, max_candidates respected, deterministic
213+
# --------------------------------------------------------------------------- #
214+
def test_pool_all_tractable_and_deterministic():
215+
opts = lambda: tg.GenOpts(pool="all", rank_with="heuristic", top=20,
216+
max_candidates=800, seed=1234)
217+
t0 = time.time()
218+
r1 = tg.generate("clan_boss", None, opts())
219+
elapsed = time.time() - t0
220+
assert elapsed < 60, f"pool=all heuristic took {elapsed:.1f}s (budget 60s)"
221+
assert len(r1) > 0
222+
# max_candidates respected.
223+
assert r1.report["candidates_evaluated"] <= 800
224+
225+
# fixed seed -> deterministic ordering.
226+
r2 = tg.generate("clan_boss", None, opts())
227+
assert [c.team for c in r1] == [c.team for c in r2]
228+
229+
230+
def test_no_engine_channel_reports_and_emits_nothing():
231+
"""A roster with no damage engine emits nothing and says why (edge case)."""
232+
res = tg.generate("clan_boss", ["Demytha"], # block-damage only, no engine
233+
tg.GenOpts(rank_with="heuristic"))
234+
assert len(res) == 0
235+
assert "error" in res.report

0 commit comments

Comments
 (0)