Skip to content

Commit a89ffef

Browse files
SnoopLawgclaude
andcommitted
organic-teams (#50 B1): tune-aware team_generator (routes hard-breakers off tunes)
team_generator now reasons about tune-COMPATIBILITY, not just roles, using the committed m5_synergy tune_compat tag: - CADENCE_KEYSTONES = (unkillable, block_damage); _is_tune_reliant(skeleton,recs) = unified skeleton whose survival rests on a cadence keystone (double_survival stalls are NOT tune-reliant). tune_compat_summary -> {tune_reliant, breakers, manageable, tunable}; tunable=False only when a tune-reliant comp carries a hard_breaker (its flat Increase SPD desyncs the cadence, e.g. Teodor). - Every CandidateComp now carries a tune_compat summary (so downstream shows WHY). - _row_tunable wired as the PRIMARY ranking key in both sort sites: tune-broken comps sink below every tunable comp (NOT dropped — hard_breakers stay welcome in stall archetypes; only banned from tune-reliant ones). This is the piece the B1 agent didn't reach before a session limit; completed here. Test fix: test_poison_engine_does_not_credit_weaken_amplifier widened top=60->300 — the #49 amp-tag fix correctly credits more hit-amplifiers, so hit/wm_gs comps outscore poison in the heuristic and pushed unified[poison] below the old window (109 tunable poison comps exist at top=300). Channel-crediting check unchanged. 15 tests pass (incl. B1's new hard-breaker-in-tune-reliant + stall-welcome tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent 34f91cd commit a89ffef

2 files changed

Lines changed: 184 additions & 6 deletions

File tree

tests/test_team_generator.py

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,8 +166,12 @@ def test_poison_engine_does_not_credit_weaken_amplifier(owned):
166166
"""A poison engine carrying needs:def_break (m5_synergy_graph adds it to all
167167
attackers) must NOT be credited a Dec-DEF/Weaken edge — that amplifier is a
168168
HIT-channel multiplier, not a poison one. Asserted via M2 resolve."""
169+
# top=300: after the 2026-06-30 amp-tag fix more heroes are correctly credited
170+
# as hit-amplifiers, so hit/wm_gs comps outscore poison comps in the heuristic
171+
# and push unified[poison] below the old top-60 window. This test verifies
172+
# channel CREDITING (not ranking), so widen the window to capture poison comps.
169173
res = tg.generate("clan_boss", owned,
170-
tg.GenOpts(rank_with="heuristic", top=60))
174+
tg.GenOpts(rank_with="heuristic", top=300))
171175
poison_comps = [c for c in res if c.skeleton == "unified[poison]"]
172176
assert poison_comps, "no poison-engine comps generated"
173177

@@ -280,6 +284,99 @@ def test_double_survival_skeleton_is_abstract_and_valid():
280284
assert any(n.startswith("double_survival") for n in names)
281285

282286

287+
# --------------------------------------------------------------------------- #
288+
# 5c. Tune awareness — a hard_breaker (flat team Inc-SPD that desyncs a fixed
289+
# speed-tune, e.g. Teodor the Savant) can't tune in a TUNE-RELIANT comp, but
290+
# is fine in a buff-coverage STALL. `manageable` (e.g. Ninja) is allowed
291+
# everywhere. Each emitted comp carries a tune_compat summary.
292+
# --------------------------------------------------------------------------- #
293+
# Tune-reliant comp = unified speed-tune skeleton whose survival rests on a
294+
# CADENCE keystone (unkillable). Hand-built (champion names live only in the
295+
# TEST, never in team_generator's source).
296+
TUNE_RELIANT_WITH_BREAKER = ["Maneater", "Demytha", "Teodor the Savant",
297+
"Venomage", "Geomancer"] # Maneater=unkillable
298+
# A buff-coverage stall (double_survival) the same hard_breaker is welcome in.
299+
STALL_WITH_BREAKER = ["Demytha", "Cardiel", "Teodor the Savant",
300+
"Venomage", "Mithrala Lifebane"]
301+
302+
303+
def test_hard_breaker_is_disqualifying_in_tune_reliant_comp():
304+
"""Teodor (hard_breaker) inside an unkillable speed-tune comp -> the comp is
305+
tune-reliant and therefore NOT tunable; _tune_ok prunes it."""
306+
recs = [r for r in (tg.record_for(n) for n in TUNE_RELIANT_WITH_BREAKER)
307+
if r is not None]
308+
assert len(recs) == len(TUNE_RELIANT_WITH_BREAKER)
309+
# the fixture really does contain a hard_breaker and a cadence keystone.
310+
assert any(r.get("tune_compat") == "hard_breaker" for r in recs)
311+
assert any(r.get("survival_currency") in tg.CADENCE_KEYSTONES for r in recs)
312+
313+
sk = tg.build_unified_skeleton("poison")
314+
assert tg._is_tune_reliant(sk, recs) is True
315+
summary = tg.tune_compat_summary(recs, sk)
316+
assert summary["tune_reliant"] is True
317+
assert "Teodor the Savant" in summary["breakers"]
318+
assert summary["tunable"] is False
319+
# the hard prune rejects it from a tune-reliant skeleton.
320+
assert tg._tune_ok(recs, sk) is False
321+
322+
323+
def test_hard_breaker_is_allowed_in_stall_archetype():
324+
"""The SAME hard_breaker is fine in a double_survival (buff-coverage) stall:
325+
the stall does not need a tight tune, so tunable is not required."""
326+
recs = [r for r in (tg.record_for(n) for n in STALL_WITH_BREAKER)
327+
if r is not None]
328+
assert len(recs) == len(STALL_WITH_BREAKER)
329+
assert any(r.get("tune_compat") == "hard_breaker" for r in recs)
330+
331+
sk = tg.build_double_survival_skeleton("poison")
332+
assert tg._is_tune_reliant(sk, recs) is False # stall -> never tune-reliant
333+
summary = tg.tune_compat_summary(recs, sk)
334+
assert summary["tune_reliant"] is False
335+
assert "Teodor the Savant" in summary["breakers"] # still REPORTED
336+
assert summary["tunable"] is True # but not disqualifying
337+
assert tg._tune_ok(recs, sk) is True
338+
339+
340+
def test_manageable_hero_allowed_in_tune_reliant_comp():
341+
"""A `manageable` hero (the tune is built AROUND it) is NOT a breaker — a
342+
tune-reliant comp containing it stays tunable."""
343+
comp = ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"]
344+
recs = [r for r in (tg.record_for(n) for n in comp) if r is not None]
345+
sk = tg.build_unified_skeleton("hit")
346+
summary = tg.tune_compat_summary(recs, sk)
347+
assert summary["tune_reliant"] is True
348+
assert "Ninja" in summary["manageable"]
349+
assert summary["breakers"] == []
350+
assert summary["tunable"] is True
351+
assert tg._tune_ok(recs, sk) is True
352+
353+
354+
def test_generated_tune_reliant_comps_never_contain_a_hard_breaker(owned):
355+
"""End-to-end: no emitted TUNE-RELIANT comp (unified + cadence keystone)
356+
places a hard_breaker; stall comps may, and every comp carries tune_compat
357+
with a consistent `tunable`."""
358+
res = tg.generate("clan_boss", owned,
359+
tg.GenOpts(rank_with="heuristic", top=200))
360+
assert len(res) > 0
361+
saw_tune_reliant = False
362+
saw_stall_with_breaker = False
363+
for c in res:
364+
tc = c.tune_compat
365+
assert tc is not None and "tunable" in tc
366+
breakers = tc["breakers"]
367+
# every emitted comp is tunable (non-tunable comps are pruned upstream).
368+
assert tc["tunable"] is True, (sorted(c.team), tc)
369+
if tc["tune_reliant"]:
370+
saw_tune_reliant = True
371+
assert breakers == [], (sorted(c.team), "breaker in tune-reliant comp")
372+
elif c.skeleton.startswith("double_survival") and breakers:
373+
saw_stall_with_breaker = True
374+
assert saw_tune_reliant, "expected at least one tune-reliant comp"
375+
# the owned roster has hard_breakers, so they MUST surface in stalls (routed
376+
# there instead of being globally banned).
377+
assert saw_stall_with_breaker, "hard_breakers were not routed to any stall"
378+
379+
283380
# --------------------------------------------------------------------------- #
284381
# 6. Tractability — pool=all under budget, max_candidates respected, deterministic
285382
# --------------------------------------------------------------------------- #

tools/team_generator.py

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
team-rule pruning (branch-and-bound `can_add`) + cheap pre-score
2121
(cb_team_explorer.predict_score) ; dedup by frozenset(team)
2222
4. M2 resolve each comp; drop comps with unmet HARD edges (no survival /
23-
broken keystone-enabler / no channel-consistent amplifier->engine)
23+
broken keystone-enabler / no channel-consistent amplifier->engine) AND
24+
tune-awareness: drop a `hard_breaker` (flat team Inc-SPD that desyncs a
25+
fixed speed-tune) from a TUNE-RELIANT comp — routing it to a stall instead
26+
(see tune_compat_summary); each emitted comp carries a tune_compat summary
2427
5. rank by M5 fitness (cb_sim for CB via rank_with=auto, heuristic elsewhere)
2528
6. novelty flag vs scraped DWJ templates
2629
@@ -88,6 +91,15 @@
8891
STRONG_KEYSTONES = ("unkillable", "block_damage", "shield",
8992
"ally_protect", "revive_on_death")
9093

94+
# CADENCE keystones: survival that must be RE-CAST on an exact SPD beat (the
95+
# Unkillable / Block-Damage recast has to land *before* the next boss AoE, which
96+
# is the whole point of a CB speed-tune). These are the keystones whose survival
97+
# is bound to a precise SPD cadence — so a flat team Increase SPD (a
98+
# `hard_breaker` hero) desyncs them and the comp can never tune.
99+
# The OTHER strong keystones (shield / ally_protect / revive_on_death) are
100+
# always-on covers a buff-coverage STALL refreshes without a tight cadence.
101+
CADENCE_KEYSTONES = ("unkillable", "block_damage")
102+
91103

92104
# ============================================================ options / output
93105
@dataclass
@@ -123,6 +135,11 @@ class CandidateComp:
123135
fitness_kind: str # "cb_sim" | "heuristic"
124136
constraint_report: dict
125137
novelty: bool
138+
# tune-compatibility summary (see tune_compat_summary): why a comp can or
139+
# can't be speed-tuned. {tune_reliant: bool, breakers: [names],
140+
# manageable: [names], tunable: bool}. Downstream (recommender / team_tune)
141+
# surfaces this so the user sees WHY a role-valid comp is/ isn't tunable.
142+
tune_compat: Optional[dict] = None
126143

127144
def as_dict(self) -> dict:
128145
return {
@@ -131,6 +148,7 @@ def as_dict(self) -> dict:
131148
"fitness": self.fitness,
132149
"fitness_kind": self.fitness_kind,
133150
"novelty": self.novelty,
151+
"tune_compat": self.tune_compat,
134152
"constraint_report": self.constraint_report,
135153
}
136154

@@ -469,6 +487,63 @@ def team_rules_report(recs: list, skeleton: Skeleton, location: str,
469487
return all_pass, rep
470488

471489

490+
# ------------------------------------------------------------ tune awareness
491+
# WHY: the generator proposes role-valid comps but is blind to whether a comp
492+
# can actually be SPEED-TUNED. A `hard_breaker` hero (m5_synergy `tune_compat`)
493+
# emits a flat team Increase SPD that desyncs any fixed speed-tune cadence —
494+
# POISON in a tune-reliant comp, but FINE in a buff-coverage stall.
495+
# `manageable` (conditional/self TM the tune is built AROUND) is allowed
496+
# everywhere — the sim/tune verifies it. `ok` has no tune issue.
497+
#
498+
# HEURISTIC for "is this comp tune-reliant?" (kept deliberately simple, per the
499+
# task brief — infer from skeleton type + survival_currency):
500+
# - The `double_survival` skeleton is a buff-COVERAGE stall: it survives via
501+
# multiple always-on keystones refreshed over the 50-turn fight, NOT a tight
502+
# SPD cadence -> NOT tune-reliant (a hard_breaker is welcome here).
503+
# - The `unified` skeleton is the MEN-style speed-tune skeleton. It is
504+
# tune-reliant when a CADENCE keystone (unkillable / block_damage, which must
505+
# be recast on an exact SPD beat) carries survival. A unified comp surviving
506+
# purely on an always-on cover (shield only) is not cadence-bound.
507+
def _is_tune_reliant(skeleton: "Skeleton", recs: list) -> bool:
508+
"""True iff this comp's SURVIVAL rests on a precise SPD cadence (see note)."""
509+
if skeleton.name.startswith("double_survival"):
510+
return False
511+
return any(r.get("survival_currency") in CADENCE_KEYSTONES for r in recs)
512+
513+
514+
def tune_compat_summary(recs: list, skeleton: "Skeleton") -> dict:
515+
"""Per-comp tune-compatibility summary carried on each CandidateComp.
516+
517+
`tunable` is False only when a tune-reliant comp contains a `hard_breaker`
518+
(its flat Increase SPD desyncs the cadence the comp's survival depends on).
519+
"""
520+
tune_reliant = _is_tune_reliant(skeleton, recs)
521+
breakers = [r["name"] for r in recs
522+
if r.get("tune_compat") == "hard_breaker"]
523+
manageable = [r["name"] for r in recs
524+
if r.get("tune_compat") == "manageable"]
525+
return {"tune_reliant": tune_reliant, "breakers": breakers,
526+
"manageable": manageable,
527+
"tunable": not (tune_reliant and breakers)}
528+
529+
530+
def _tune_ok(recs: list, skeleton: "Skeleton") -> bool:
531+
"""A `hard_breaker` is disqualifying ONLY in a tune-reliant comp (it can't
532+
tune); in a stall it is allowed. Used as a HARD ranking PENALTY (not a drop):
533+
non-tunable comps sink BELOW every tunable comp, so the recommended top is
534+
breaker-free tune-reliant comps + stalls, and hard_breakers are routed toward
535+
the `double_survival` stall archetype rather than blanket-banned. Comps stay
536+
in the output (marked tune_compat.tunable=False) so downstream sees WHY."""
537+
return tune_compat_summary(recs, skeleton)["tunable"]
538+
539+
540+
def _row_tunable(row: list) -> bool:
541+
"""Tunability of a scored row ([0]=team names, [1]=skeleton). Primary rank
542+
key so the recommended top is always tunable; non-tunable comps sink last."""
543+
recs = [r for r in (record_for(n) for n in row[0]) if r is not None]
544+
return _tune_ok(recs, row[1])
545+
546+
472547
def can_add(partial_recs: list, rec: dict, location: str,
473548
faction_lock: bool) -> bool:
474549
"""Incremental branch-and-bound prune (only HARD, monotone constraints).
@@ -1032,8 +1107,11 @@ def _cb_rank(scored: list, survivors: list, roster_recs: list,
10321107
rep[4], rep[5], rep[6] = val, "cb_sim", bd
10331108

10341109
all_rows = scored + extra_rows
1035-
# simmed comps (kind cb_sim) outrank heuristic ones; tie-break by value.
1036-
all_rows.sort(key=lambda x: (0 if x[5] == "cb_sim" else 1, -x[4]))
1110+
# tunable comps first (a tune-reliant comp carrying a hard_breaker can't hold
1111+
# its cadence — sink it below every tunable comp); then simmed (cb_sim) over
1112+
# heuristic; then by value.
1113+
all_rows.sort(key=lambda x: (0 if _row_tunable(x) else 1,
1114+
0 if x[5] == "cb_sim" else 1, -x[4]))
10371115
return all_rows, n1, n2
10381116

10391117

@@ -1133,7 +1211,8 @@ def generate(location: str, roster: list, opts: Optional[GenOpts] = None
11331211
pre = skeleton_prescore(recs, sk, roles_by_hero)
11341212
scored.append([team, sk, rr, rule_rep, h["fitness"], "heuristic",
11351213
h.get("breakdown", {}), pre, core])
1136-
scored.sort(key=lambda x: -x[4])
1214+
# tunable comps first (tune-reliant + hard_breaker can't tune -> sinks last).
1215+
scored.sort(key=lambda x: (0 if _row_tunable(x) else 1, -x[4]))
11371216

11381217
if rank_with == "cb_sim" and is_cb:
11391218
scored, n_phase1, n_phase2 = _cb_rank(
@@ -1147,10 +1226,12 @@ def generate(location: str, roster: list, opts: Optional[GenOpts] = None
11471226
for team, sk, rr, rule_rep, fit, kind, bd, _pre, _core in scored[:opts.top]:
11481227
rep = dict(rule_rep)
11491228
rep["fitness_breakdown"] = bd
1229+
recs = [r for r in (record_for(n) for n in team) if r is not None]
11501230
out.append(CandidateComp(
11511231
team=team, skeleton=sk.name, resolve=rr, fitness=fit,
11521232
fitness_kind=kind, constraint_report=rep,
1153-
novelty=_is_novel(team)))
1233+
novelty=_is_novel(team),
1234+
tune_compat=tune_compat_summary(recs, sk)))
11541235
report["candidates_evaluated"] = len(survivors)
11551236
report["candidates_returned"] = len(out)
11561237
return _Result(out, report)

0 commit comments

Comments
 (0)