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
8891STRONG_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+
472547def 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