Skip to content

Commit 34f91cd

Browse files
SnoopLawgclaude
andcommitted
organic-teams (#50 B2): team_tune flags UNTUNABLE comps (tune-breaker aware)
team_tune.py now reads each member's tune_compat (m5_synergy via fitness.synergy_data; safe-default ok). If a comp contains a hard_breaker (flat team Increase SPD, e.g. Teodor), it SKIPS the SPD search and returns tunable=False + untunable_reason "<hero>'s Increase SPD breaks the speed-tune cadence", scored as a STALL on natural speeds (one cb_sim run) — instead of the misleading "dies T41" pseudo-tune. manageable (Ninja)/ok comps take the exact same full-search path as before. Schema gains tunable + untunable_reason on every return path; --compare renders UNTUNABLE + reason in place of a fake number. Real --compare (spirit): MEN 37.59M holds T50 (tunable); MEN+Teodor UNTUNABLE (Teodor Increase SPD), stall T28/22.8M. 11 tests pass (9 + classifier + hard-breaker-flagged). cb_sim stall score still labelled ESTIMATE. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent c1e8a18 commit 34f91cd

2 files changed

Lines changed: 211 additions & 22 deletions

File tree

tests/test_team_tune.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@
1919

2020
MEN = ["Maneater", "Demytha", "Ninja", "Geomancer", "Venomage"]
2121
NOVEL = ["Arbiter", "Coldheart", "Demytha", "Ninja", "Teodor the Savant"]
22-
SCHEMA = ("spd_assignment", "tuned_fitness", "holds_t50", "tune_found", "notes")
22+
# MEN with the hard_breaker Teodor the Savant swapped in for Venomage — a comp
23+
# that is UNTUNABLE by construction (Teodor's flat team Increase SPD breaks any
24+
# fixed speed-tune). 4/5 heroes are MEN so it sets up on current gear.
25+
TEODOR_COMP = ["Maneater", "Demytha", "Ninja", "Geomancer", "Teodor the Savant"]
26+
SCHEMA = ("spd_assignment", "tuned_fitness", "holds_t50", "tune_found",
27+
"tunable", "untunable_reason", "notes")
2328

2429

2530
def test_resolve_element():
@@ -76,6 +81,52 @@ def test_tune_and_score_men_schema():
7681
assert res["base_spd"]["Demytha"] in (174, 184)
7782

7883

84+
def test_tune_compat_classifier():
85+
"""tune_compat is read from m5_synergy.jsonl with a safe 'ok' default; the
86+
hard_breaker scan flags exactly the offending member(s)."""
87+
assert team_tune._tune_compat("Teodor the Savant") == "hard_breaker"
88+
assert team_tune._tune_compat("Ninja") == "manageable" # NOT a breaker
89+
assert team_tune._tune_compat("Maneater") == "ok"
90+
assert team_tune._tune_compat("Definitely Not A Hero") == "ok" # safe default
91+
# MEN (Ninja is manageable, allowed) has no hard_breaker; Teodor comp does.
92+
assert team_tune._hard_breakers(MEN) == []
93+
assert team_tune._hard_breakers(TEODOR_COMP) == ["Teodor the Savant"]
94+
95+
96+
def test_hard_breaker_comp_is_flagged_untunable():
97+
"""A comp containing a hard_breaker (Teodor the Savant) is reported as
98+
UNTUNABLE: tunable=False + a non-empty reason naming the SPD break, and it
99+
does NOT claim a holding speed-tune (tune_found=False, no SPD search). MEN —
100+
whose Ninja is `manageable` (allowed) — still tunes normally (tunable=True).
101+
"""
102+
res = team_tune.tune_and_score(TEODOR_COMP, element="spirit", gear="current")
103+
# full schema still present
104+
for k in SCHEMA:
105+
assert k in res, f"missing schema key {k}"
106+
# untunable verdict
107+
assert res["tunable"] is False
108+
assert res["untunable_reason"] # non-empty
109+
assert "SPD" in res["untunable_reason"] # mentions the SPD break
110+
assert "Teodor the Savant" in res["untunable_reason"]
111+
# does NOT claim a found holding speed-tune
112+
assert res["tune_found"] is False
113+
# scored as a STALL on natural speeds: a SINGLE sim, no SPD search
114+
assert res["combos_evaluated"] == 1
115+
assert res["natural"] is not None
116+
assert isinstance(res["tuned_fitness"], float)
117+
# a note explains it was scored as a stall (untunable)
118+
assert any("stall" in n.lower() and "untunable" in n.lower()
119+
for n in res["notes"])
120+
121+
# MEN still tunes normally (tunable=True, no untunable reason). Tiny explicit
122+
# vary keeps it to ~2 sims.
123+
men = team_tune.tune_and_score(MEN, element="spirit", gear="current",
124+
vary={"Demytha": [174, 184]})
125+
assert men["tunable"] is True
126+
assert men["untunable_reason"] == ""
127+
assert set(men["spd_assignment"]) == set(MEN)
128+
129+
79130
@pytest.fixture(scope="module")
80131
def gear_opt():
81132
"""Shared vault-wide gear optimizer (building it indexes the whole vault, so

tools/team_tune.py

Lines changed: 159 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,47 @@ def _base_name(name: str) -> str:
210210
return name
211211

212212

213+
# ---- tune_compat (UNTUNABLE-by-construction detection, task #50 B2) --------- #
214+
# A comp containing a `hard_breaker` (a hero whose kit puts FLAT, team-wide
215+
# Increase SPD on every cycle — e.g. Teodor the Savant) cannot hold a FIXED
216+
# speed-tune: the recurring SPD buff shifts every champion's turn-meter gain, so
217+
# any cadence the search "finds" desyncs the moment the buff lands/expires. The
218+
# M1 record's `tune_compat` field (data/m5_synergy.jsonl) flags this:
219+
# hard_breaker = flat team Increase SPD -> NO fixed tune holds (untunable).
220+
# manageable = conditional/self TM only (Ninja) -> tunes build around it.
221+
# ok = no cadence interference.
222+
# We DETECT hard_breaker and EXPLAIN it instead of reporting a doomed tune.
223+
def _tune_compat(name: str) -> str:
224+
"""`tune_compat` for one hero (hard_breaker | manageable | ok), safe-default
225+
'ok' if the hero is missing from m5_synergy.jsonl or the loader is absent."""
226+
try:
227+
import fitness.synergy_data as sd
228+
rec = sd.get_record(_base_name(name))
229+
if rec:
230+
return str(rec.get("tune_compat") or "ok").lower()
231+
except Exception:
232+
pass
233+
return "ok"
234+
235+
236+
def _hard_breakers(comp: list[str]) -> list[str]:
237+
"""Names in `comp` flagged hard_breaker (flat team Increase SPD). A non-empty
238+
list means the comp is UNTUNABLE by construction — no fixed speed-tune holds.
239+
`manageable` (Ninja) and `ok` are NOT breakers (full tune search as today)."""
240+
return [nm for nm in comp if _tune_compat(nm) == "hard_breaker"]
241+
242+
243+
def _untunable_reason(breakers: list[str]) -> str:
244+
"""Human reason for the untunable verdict, naming the offending hero(es)."""
245+
names = ", ".join(breakers)
246+
return f"{names}'s Increase SPD breaks the speed-tune cadence"
247+
248+
249+
def _untunable_marker(breakers: list[str]) -> str:
250+
"""Short --compare marker, e.g. 'UNTUNABLE (Teodor the Savant Increase SPD)'."""
251+
return f"UNTUNABLE ({', '.join(breakers)} Increase SPD)"
252+
253+
213254
def _cb_acc_floor(location: str = "clan_boss") -> int:
214255
"""ACC floor (boss-RES-derived) from game-truth boss_constraints; falls back
215256
to the CB-UNM literal if the constraint table can't be loaded."""
@@ -338,7 +379,52 @@ def _gear_feasibility_note(gf: dict) -> str:
338379

339380

340381
_SCHEMA_KEYS = ("spd_assignment", "tuned_fitness", "holds_t50",
341-
"tune_found", "notes")
382+
"tune_found", "tunable", "untunable_reason", "notes")
383+
384+
385+
def _untunable_result(comp_names, setup, base_spd, element_id, gear,
386+
breakers, notes) -> dict:
387+
"""A hard_breaker comp is UNTUNABLE by construction (a fixed speed-tune can't
388+
hold the cadence). DO NOT search SPD-space for a holding tune; instead score
389+
the comp ONCE on its NATURAL speeds as a STALL (a hard_breaker comp may still
390+
survive without a tune) and EXPLAIN why no tune is reported.
391+
"""
392+
base_combo = tuple(base_spd)
393+
natural = _score_combo(setup, base_combo, element_id)
394+
reason = _untunable_reason(breakers)
395+
notes.append(
396+
f"UNTUNABLE by construction: {reason}. A FIXED speed-tune cannot hold "
397+
f"the boss cadence for this comp, so NO holding speed-tune is reported "
398+
f"(the SPD search was skipped — it would only surface a doomed pseudo-"
399+
f"tune).")
400+
notes.append(
401+
f"Scored as a STALL on natural speeds (untunable): T{natural['turns']} "
402+
f"/ {natural['total']/1e6:.1f}M — a hard_breaker comp can still survive "
403+
f"as a stall without a tune.")
404+
notes.append("Damage is a cb_sim ESTIMATE (sim under-survives, over-rates "
405+
"stalls); use it to rank, not as an absolute clear check.")
406+
return {
407+
# natural speeds, clearly NOT presented as a found tune (tune_found=False,
408+
# tunable=False) — informational so the user sees the actual speeds.
409+
"spd_assignment": dict(zip(comp_names, [int(x) for x in base_combo])),
410+
"tuned_fitness": natural["total"],
411+
"holds_t50": natural["holds"],
412+
"tune_found": False,
413+
"tunable": False,
414+
"untunable_reason": reason,
415+
"notes": notes,
416+
# extras
417+
"base_spd": dict(zip(comp_names, base_spd)),
418+
"natural": natural,
419+
"best": natural,
420+
"combos_evaluated": 1,
421+
"gear": gear,
422+
"gear_feasibility": None,
423+
"element": element_id,
424+
"source": "hard_breaker detection -> natural-speed STALL sim "
425+
"(no SPD search)",
426+
"team": list(comp_names),
427+
}
342428

343429

344430
def tune_and_score(comp: list[str], *, element="spirit", gear: str = "current",
@@ -382,6 +468,12 @@ def tune_and_score(comp: list[str], *, element="spirit", gear: str = "current",
382468
element_id = _resolve_element(element)
383469
notes: list[str] = []
384470

471+
# --- UNTUNABLE-by-construction gate (task #50 B2). ----------------------- #
472+
# If the comp contains a hard_breaker (flat team Increase SPD), no FIXED
473+
# speed-tune can hold the boss cadence. Detect up front so we never present a
474+
# doomed pseudo-tune; we still score it as a natural-speed STALL below.
475+
breakers = _hard_breakers(comp)
476+
385477
# --- build the team once (real gear + flagship preset). ----------------- #
386478
try:
387479
from cb_sim import _build_team_setup, SPD
@@ -392,12 +484,18 @@ def tune_and_score(comp: list[str], *, element="spirit", gear: str = "current",
392484
if isinstance(setup, dict) and setup.get("error"):
393485
# Can't set up on current/optimal gear (e.g. unowned + no gear). Fall
394486
# back to the potential-gear NATURAL sim — UNTUNED, clearly labelled.
395-
return _potential_fallback(comp, element_id, gear, setup["error"], notes)
487+
return _potential_fallback(comp, element_id, gear, setup["error"], notes,
488+
breakers=breakers)
396489

397490
hero_names = setup["hero_names"]
398491
base_spd = [int(round(setup["stats_per_hero"][i][SPD]))
399492
for i in range(len(hero_names))]
400493

494+
if breakers:
495+
# Skip the SPD search entirely — score natural speeds as a stall + explain.
496+
return _untunable_result(hero_names, setup, base_spd, element_id, gear,
497+
breakers, notes)
498+
401499
# --- build the SPD search grid (slot-indexed). -------------------------- #
402500
import itertools
403501
n = len(hero_names)
@@ -512,6 +610,8 @@ def _score(combo):
512610
"tuned_fitness": best["total"],
513611
"holds_t50": best["holds"],
514612
"tune_found": tune_found,
613+
"tunable": True,
614+
"untunable_reason": "",
515615
"notes": notes,
516616
# extras
517617
"base_spd": dict(zip(hero_names, base_spd)),
@@ -543,10 +643,21 @@ def _normalize_vary(vary, hero_names) -> dict:
543643
return out
544644

545645

546-
def _potential_fallback(comp, element_id, gear, err, notes) -> dict:
646+
def _potential_fallback(comp, element_id, gear, err, notes,
647+
breakers: Optional[list[str]] = None) -> dict:
547648
"""Current/optimal-gear setup failed — report the potential-gear NATURAL
548649
(untuned) sim so the caller still gets a comparable number, clearly flagged
549-
as UNTUNED."""
650+
as UNTUNED.
651+
652+
`breakers` (hard_breaker members) is carried through so the result still
653+
reports tunable=False + the reason even when the comp couldn't be set up on
654+
current/optimal gear (the SPD search never runs in this path anyway)."""
655+
breakers = breakers or []
656+
tunable = not breakers
657+
untunable_reason = _untunable_reason(breakers) if breakers else ""
658+
if breakers:
659+
notes.append(f"UNTUNABLE by construction: {untunable_reason} "
660+
f"(no fixed speed-tune holds; SPD search skipped).")
550661
notes.append(f"Could not build the comp on {gear} gear ({err}). "
551662
f"speed_tune_finder needs gearable (owned) heroes to tune; "
552663
f"falling back to the potential-gear NATURAL sim — UNTUNED.")
@@ -558,7 +669,9 @@ def _potential_fallback(comp, element_id, gear, err, notes) -> dict:
558669
if r.get("error"):
559670
notes.append(f"Potential-gear sim also failed: {r['error']}.")
560671
return {"spd_assignment": None, "tuned_fitness": 0.0,
561-
"holds_t50": False, "tune_found": False, "notes": notes,
672+
"holds_t50": False, "tune_found": False,
673+
"tunable": tunable, "untunable_reason": untunable_reason,
674+
"notes": notes,
562675
"natural": None, "best": None, "combos_evaluated": 0,
563676
"gear": "potential", "gear_feasibility": None,
564677
"element": element_id,
@@ -568,7 +681,9 @@ def _potential_fallback(comp, element_id, gear, err, notes) -> dict:
568681
notes.append("TUNED damage is a cb_sim ESTIMATE; this is the UNTUNED "
569682
"potential-gear floor (no SPD search ran).")
570683
return {"spd_assignment": None, "tuned_fitness": total,
571-
"holds_t50": holds, "tune_found": False, "notes": notes,
684+
"holds_t50": holds, "tune_found": False,
685+
"tunable": tunable, "untunable_reason": untunable_reason,
686+
"notes": notes,
572687
"natural": {"turns": r.get("cb_turns"), "gaps": None,
573688
"holds": holds, "total": total},
574689
"best": {"turns": r.get("cb_turns"), "gaps": None,
@@ -625,9 +740,12 @@ def _print_compare(rows, element_id: int) -> None:
625740
f"{'turns':>5} {'gear':>9} team")
626741
print(" " + "-" * 100)
627742
for i, (label, team, res) in enumerate(rows, 1):
628-
dmg = f"{res['tuned_fitness']/1e6:.2f}M"
743+
untunable = res.get("tunable") is False
744+
# For an untunable comp show the marker in place of a tuned number, so it
745+
# reads "UNTUNABLE (...)" rather than a fake "31.1M dies T41" tune.
746+
dmg = "UNTUNABLE" if untunable else f"{res['tuned_fitness']/1e6:.2f}M"
629747
holds = "Y" if res["holds_t50"] else "."
630-
tuned = "Y" if res["tune_found"] else "."
748+
tuned = "UNT" if untunable else ("Y" if res["tune_found"] else ".")
631749
turns = res["best"]["turns"] if res.get("best") else "?"
632750
gf = res.get("gear_feasibility")
633751
gear_c = ("buildable" if gf and gf["feasible"]
@@ -636,6 +754,7 @@ def _print_compare(rows, element_id: int) -> None:
636754
f"{gear_c:>9} {label}")
637755
print()
638756
for label, team, res in rows:
757+
untunable = res.get("tunable") is False
639758
spd = res.get("spd_assignment")
640759
spd_s = (", ".join(f"{k}={v}" for k, v in spd.items())
641760
if spd else "(no SPD tune — see notes)")
@@ -644,10 +763,19 @@ def _print_compare(rows, element_id: int) -> None:
644763
f"{(nat.get('total') or 0)/1e6:.1f}M"
645764
if nat else "n/a")
646765
print(f" • {label}")
647-
print(f" tuned SPD: {spd_s}")
648-
print(f" tuned: T{res['best']['turns'] if res.get('best') else '?'}"
649-
f"/{res['tuned_fitness']/1e6:.1f}M vs {nat_s} "
650-
f"(combos={res['combos_evaluated']}, gear={res['gear']})")
766+
if untunable:
767+
# No fake tuned number — explain the verdict instead.
768+
print(f" {_untunable_marker(_hard_breakers(team))}: "
769+
f"{res.get('untunable_reason', '')}")
770+
print(f" scored as STALL (untunable): "
771+
f"T{res['best']['turns'] if res.get('best') else '?'}"
772+
f"/{res['tuned_fitness']/1e6:.1f}M natural speeds "
773+
f"(combos={res['combos_evaluated']}, gear={res['gear']})")
774+
else:
775+
print(f" tuned SPD: {spd_s}")
776+
print(f" tuned: T{res['best']['turns'] if res.get('best') else '?'}"
777+
f"/{res['tuned_fitness']/1e6:.1f}M vs {nat_s} "
778+
f"(combos={res['combos_evaluated']}, gear={res['gear']})")
651779
gf = res.get("gear_feasibility")
652780
if gf:
653781
verdict = "BUILDABLE" if gf["feasible"] else "NOT buildable as-is"
@@ -755,16 +883,26 @@ def _print_single(team, res) -> None:
755883
res["element"], res["element"])
756884
print(f"\n=== team_tune: {', '.join(team)} (element={elem}, "
757885
f"gear={res['gear']}) ===")
758-
spd = res.get("spd_assignment")
759-
if spd:
760-
print("tuned SPD:", ", ".join(f"{k}={v}" for k, v in spd.items()))
886+
untunable = res.get("tunable") is False
761887
nat = res.get("natural") or {}
762-
print(f"natural : T{nat.get('turns')} / "
763-
f"{(nat.get('total') or 0)/1e6:.2f}M")
764-
print(f"tuned : T{res['best']['turns'] if res.get('best') else '?'} / "
765-
f"{res['tuned_fitness']/1e6:.2f}M "
766-
f"holds_t50={res['holds_t50']} tune_found={res['tune_found']} "
767-
f"(combos={res['combos_evaluated']})")
888+
if untunable:
889+
print(_untunable_marker(_hard_breakers(team)) + ":",
890+
res.get("untunable_reason", ""))
891+
print(f"natural : T{nat.get('turns')} / "
892+
f"{(nat.get('total') or 0)/1e6:.2f}M")
893+
print(f"stall : T{res['best']['turns'] if res.get('best') else '?'} / "
894+
f"{res['tuned_fitness']/1e6:.2f}M holds_t50={res['holds_t50']} "
895+
f"tunable=False (scored as stall, no SPD search)")
896+
else:
897+
spd = res.get("spd_assignment")
898+
if spd:
899+
print("tuned SPD:", ", ".join(f"{k}={v}" for k, v in spd.items()))
900+
print(f"natural : T{nat.get('turns')} / "
901+
f"{(nat.get('total') or 0)/1e6:.2f}M")
902+
print(f"tuned : T{res['best']['turns'] if res.get('best') else '?'} / "
903+
f"{res['tuned_fitness']/1e6:.2f}M "
904+
f"holds_t50={res['holds_t50']} tune_found={res['tune_found']} "
905+
f"(combos={res['combos_evaluated']})")
768906
gf = res.get("gear_feasibility")
769907
if gf:
770908
verdict = "BUILDABLE" if gf["feasible"] else "NOT buildable as-is"

0 commit comments

Comments
 (0)