@@ -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+
213254def _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
344430def 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