@@ -188,10 +188,46 @@ def build_unified_skeleton(channel: str, size: int = 5) -> Skeleton:
188188 return Skeleton (name = f"unified[{ channel } ]" , slots = slots , channel = channel )
189189
190190
191- # Named presets are THIN wrappers over the unified skeleton (spec).
191+ def build_double_survival_skeleton (channel : str , size : int = 5 ) -> Skeleton :
192+ """A multi-keystone STALL skeleton bound to one engine `channel`: TWO
193+ distinct survival slots (+ optional enabler + engine + flex).
194+
195+ Slots are TAG PREDICATES only — no champion names. The single-survival
196+ `unified` skeleton under-produces 2-/3-currency stalls (the Batman Forever /
197+ Budget-UK / Deacon / CatEater family) because two sustained keystones rarely
198+ co-occur in one beam; a dedicated second survival slot makes the
199+ multi-keystone stall systematic. No amplifier slot — these are the
200+ amp-less passive-damage stalls (the amplifier remains a SCORED preference via
201+ skeleton_prescore when one happens to fill a flex/engine slot).
202+ """
203+ keystone_preds = [f"survival:{ k } " for k in STRONG_KEYSTONES ]
204+ slots = [
205+ SlotConstraint ("survival" , list (keystone_preds ), optional = False ),
206+ SlotConstraint ("survival2" , list (keystone_preds ), optional = False ),
207+ # enabler conditionally required (team rule enabler_if_keystone);
208+ # modeled optional so the search fills it when a keystone needs it.
209+ SlotConstraint ("enabler" , ["enabler:any" ], optional = True ),
210+ SlotConstraint ("engine" , [f"engine:{ channel } " ], optional = False ),
211+ ]
212+ flex_pred = [f"engine:{ channel } " , "dot_detonate" , "cleanse" , "heal" ]
213+ slots .append (SlotConstraint ("flex" , flex_pred , optional = True ))
214+ while len (slots ) < size :
215+ slots .append (SlotConstraint (f"flex{ len (slots )} " , flex_pred ,
216+ optional = True ))
217+ slots = slots [:max (size , 4 )]
218+ return Skeleton (name = f"double_survival[{ channel } ]" , slots = slots ,
219+ channel = channel )
220+
221+
222+ # Named presets are THIN wrappers over the unified skeleton (spec). The unified
223+ # (primary) skeletons are emitted FIRST for every channel, then the
224+ # double_survival (multi-keystone stall) variants — so a comp the canonical
225+ # unified skeleton can build is attributed to it (the stall variant only claims
226+ # the lean comps unified cannot produce, via the cross-skeleton team dedup).
192227def build_named_skeletons (channels_present : set , size : int = 5 ) -> list :
193- out = [build_unified_skeleton (ch , size ) for ch in ENGINE_CHANNELS
194- if ch in channels_present ]
228+ present = [ch for ch in ENGINE_CHANNELS if ch in channels_present ]
229+ out = [build_unified_skeleton (ch , size ) for ch in present ]
230+ out += [build_double_survival_skeleton (ch , size ) for ch in present ]
195231 return out
196232
197233
@@ -357,13 +393,23 @@ def filter_roster(roster_names: list, location: str, cb_element: int,
357393
358394# ============================================================ team-rule checks
359395def _channel_consistent (recs : list , channel : str ) -> bool :
360- has_engine = any (channel in (r .get ("engine_channel" ) or []) for r in recs )
361- if not has_engine :
362- return False
363- amp_ch = AMP_FOR_ENGINE [channel ]
364- if amp_ch is None : # hp_burn — engine presence is enough
365- return True
366- return any (r .get ("amplifier_channel" ) == amp_ch for r in recs )
396+ """A comp is channel-consistent iff it fields a damage ENGINE of `channel`.
397+
398+ RELAXED (task #45, 2026-06): the engine's channel-correct AMPLIFIER
399+ (Dec-DEF/Weaken for hit/wm_gs/bring_it_down; Poison-Sens for poison) used to
400+ be HARD-REQUIRED here, which structurally excluded the amp-less pure-hit /
401+ WM-GS stall tunes (BatManSaladEater, Bateater, Man Salad, Myth Heir Salad).
402+ Those stalls are VIABLE without an amplifier — damage accrues passively over
403+ the 50-turn stall (WM/GS procs + DoTs). The amplifier genuinely HELPS (e.g.
404+ our capture showed WM procced at calc_raw 93750 = 75000 x 1.25 Weaken), so
405+ it is now a strong SCORED preference (skeleton_prescore) instead of a gate:
406+ amp-less stalls are FEASIBLE but rank below their amp'd equivalents.
407+
408+ Channel-CORRECTNESS for CREDITING is unchanged: M2 resolve never routes a
409+ hit amplifier to a poison engine, and skeleton_prescore only credits an
410+ amplifier whose channel matches AMP_FOR_ENGINE[channel].
411+ """
412+ return any (channel in (r .get ("engine_channel" ) or []) for r in recs )
367413
368414
369415def team_rules_report (recs : list , skeleton : Skeleton , location : str ,
@@ -494,6 +540,43 @@ def skeleton_prescore(recs: list, skeleton: Skeleton,
494540 amp_ch = AMP_FOR_ENGINE [ch ]
495541 score = 0.0
496542 enablers_present = {r ["enabler" ] for r in recs if r .get ("enabler" )}
543+
544+ # ---- multi-keystone STALL skeleton: score by SURVIVAL DEPTH, not damage. -
545+ # The double_survival archetype's value is COVERAGE — multiple distinct,
546+ # enabler-satisfied keystones holding the boss AoE/stun for 50 turns — and
547+ # its damage accrues passively. Scoring it with the damage-breadth/amplifier
548+ # terms below would let rich amp'd cores out-compete the lean multi-keystone
549+ # cores out of the per-anchor beam, which is exactly what under-produced the
550+ # amp-less multi-keystone stall signatures (Batman / Budget-UK / Deacon
551+ # tunes). So a stall core is ranked
552+ # by how many distinct keystone currencies it lands (each weighted by
553+ # enabler satisfaction), with only a light channel-engine presence nudge.
554+ if skeleton .name .startswith ("double_survival" ):
555+ per_currency : dict = {}
556+ for r in recs :
557+ cur = r .get ("survival_currency" )
558+ if not cur :
559+ continue
560+ w = sd .survival_weight (r )
561+ if r .get ("keystone_needs_enabler" ):
562+ compat = KEYSTONE_ENABLER_COMPAT .get (cur , set ())
563+ w *= 1.0 if (enablers_present & compat ) else 0.25
564+ per_currency [cur ] = max (per_currency .get (cur , 0.0 ), w )
565+ # depth across DISTINCT currencies (so a 2nd/3rd cover is rewarded,
566+ # unlike the capped single-survival coverage term below).
567+ score += 3.0 * sum (per_currency .values ())
568+ if any (ch in (r .get ("engine_channel" ) or []) for r in recs ):
569+ score += 1.0
570+ # detonation is the only damage lever a passive stall really wants.
571+ if any ("dot_detonate" in r .get ("provides" , []) for r in recs ):
572+ score += 0.3
573+ if roles_by_hero is not None :
574+ flat : set = set ()
575+ for r in recs :
576+ flat |= roles_by_hero .get (r ["name" ], set ())
577+ score += 0.05 * len (flat )
578+ return score
579+
497580 # Survival COVERAGE — a stall needs the boss AoE/stun covered, but only ~2
498581 # *sustained* strong keystones; beyond that the extra survival slot is a
499582 # wasted damage slot (cb_sim does not reward a 3rd cover). So coverage is
@@ -752,30 +835,39 @@ def _struct(recs):
752835
753836
754837# ============================================================ hard-edge drop
755- def _value_edge_ok (resolve_result , channel : str ) -> bool :
756- """A comp must carry a SATISFIED channel-consistent amplifier->engine edge
757- (M2). hit-class engines need a Dec-DEF/Weaken (ch="hit") edge; poison needs
758- a poison_synergy edge; hp_burn has no amplifier (engine presence suffices).
838+ def _value_edge_present (resolve_result , channel : str ) -> bool :
839+ """Whether the comp carries a SATISFIED channel-consistent amplifier->engine
840+ edge (M2). hit-class engines look for a Dec-DEF/Weaken (ch="hit") edge;
841+ poison for a poison_synergy edge; hp_burn has no amplifier channel.
842+
843+ This is now a PREFERENCE signal (see _channel_consistent / skeleton_prescore)
844+ rather than a hard gate — an amp-less stall has no such edge yet is still
845+ feasible. The channel CHECK stays channel-correct so a poison engine never
846+ treats a hit (def_break) edge as its multiplier.
759847 """
760848 sat = resolve_result .satisfied
761849 if channel in ("hit" , "wm_gs" , "bring_it_down" ):
762850 return any (e .tag == "def_break" and e .channel == "hit" for e in sat )
763851 if channel == "poison" :
764852 return any (e .tag == "poison_synergy" for e in sat )
765- if channel == "hp_burn" :
766- return True
767- return False
853+ return False # hp_burn (and anything else): no amplifier channel
768854
769855
770856def _hard_edges_ok (resolve_result , recs : list , channel : str ) -> tuple :
771- """Drop comps with unmet HARD edges (spec step 6). Returns (ok, reason)."""
857+ """Drop comps with unmet HARD edges (spec step 6). Returns (ok, reason).
858+
859+ The HARD edges are: a survival currency, every keystone's enabler satisfied,
860+ and a channel-consistent damage ENGINE. The channel-correct AMPLIFIER is NO
861+ longer a hard edge (task #45) — amp-less stalls accrue damage passively, so
862+ the amplifier is a scored preference (skeleton_prescore), not a gate.
863+ """
772864 if not any (r .get ("survival_currency" ) for r in recs ):
773865 return False , "no survival currency"
774866 for k in resolve_result .keystones :
775867 if k .get ("needs_enabler" ) and not k .get ("enabler_ok" ):
776868 return False , f"broken keystone-enabler ({ k ['keystone' ]} )"
777- if not _value_edge_ok ( resolve_result , channel ):
778- return False , f"no channel-consistent amplifier->engine for { channel } "
869+ if not _channel_consistent ( recs , channel ):
870+ return False , f"no channel-consistent { channel } engine "
779871 return True , "ok"
780872
781873
0 commit comments