Skip to content

Commit 5f3b699

Browse files
SnoopLawgclaude
andcommitted
organic-teams M4 refinement (#45): rediscovery 63% -> 95% on the principled engine
Recovers the M6 rediscovery misses by relaxing over-strict feasibility (the harness surfaced these). team_generator.py: 1. _channel_consistent: amplifier REQUIRE -> PREFERENCE. A comp is feasible with survival + a channel-consistent ENGINE; the channel-correct amplifier (Dec-DEF/ Weaken) is scored (skeleton_prescore) not gated. Crediting stays channel-correct (M2 resolve untouched: poison engine still never credited Weaken). Recovers the 3 amp-less WM/GS pure-stalls (amp-less stalls are viable; WM IS x1.25 Weaken but not REQUIRED). 2. _hard_edges_ok: amplifier->engine edge no longer a drop reason (survival + keystone-enablers + channel engine remain hard). 3. New double_survival skeleton (>1 survival slot, abstract tag-predicates, zero champion names) + stall-aware skeleton_prescore (score double_survival cores by distinct survival-currency DEPTH so lean multi-keystone stalls survive the beam). Recovers 3 of 4 multi-keystone stalls. RESULT (rediscovery_harness, deterministic): 12/19=63% -> 18/19=95% distinct signatures (34/35=97% per-tune) — exceeds the broad-POC 89%, now via the DISCIPLINED generative engine (prunes sub-viable). 6 of 7 misses recovered. Still missed (1): Batman Forever — surv[shield+unkillable], enab[0], exact signature; only owned buff_extension enablers (Corvis/Demytha) perturb the signature, so unreachable without relaxing the keystone-enabler rule (out of scope, kept). Honest. 11 tests pass (9 existing incl. no-champion-names + channel-correct crediting + cb_sim rediscovery of user comp; + ampless-WM/GS-stall-feasible + double_survival- abstract-valid). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SCKiWFsFcWRBkL4kWf1q6b
1 parent f5f2695 commit 5f3b699

2 files changed

Lines changed: 184 additions & 20 deletions

File tree

tests/test_team_generator.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,78 @@ def test_resolve_channel_gate_rejects_weaken_to_poison_directly():
208208
and "channel mismatch" in n for n in rr.notes), rr.notes
209209

210210

211+
# --------------------------------------------------------------------------- #
212+
# 5b. Channel-consistent AMPLIFIER is a PREFERENCE, not a gate (task #45).
213+
# An amp-less WM/GS stall (no Dec-DEF/Weaken amplifier) used to be
214+
# structurally EXCLUDED by the old hard `channel_consistent` rule; it is now
215+
# FEASIBLE — the stall accrues damage passively over the 50-turn fight, and
216+
# the amplifier is rewarded only by the structural pre-score. Crediting
217+
# stays channel-correct: NO amplifier->engine edge is credited.
218+
# --------------------------------------------------------------------------- #
219+
AMPLESS_WM_GS_STALL = ["Coldheart", "Scyl of the Drakes", "Demytha",
220+
"Maneater", "Apothecary"]
221+
222+
223+
def test_ampless_wm_gs_stall_is_now_feasible():
224+
recs = [r for r in (tg.record_for(n) for n in AMPLESS_WM_GS_STALL)
225+
if r is not None]
226+
assert len(recs) == len(AMPLESS_WM_GS_STALL), "fixture heroes must be owned"
227+
228+
# (a) genuinely amp-less: no hero is a hit-channel amplifier AND no hero
229+
# provides the Dec-DEF/Weaken multiplier the old HARD rule demanded.
230+
assert not any(r.get("amplifier_channel") == "hit" for r in recs)
231+
amp_types = set()
232+
for r in recs:
233+
amp_types |= tg.sd.hit_amplifier_types(r)
234+
assert amp_types == set(), amp_types
235+
236+
# (b) the OLD rule required an amplifier whose channel == AMP_FOR_ENGINE for
237+
# the bound channel; with none present it would have been EXCLUDED. The
238+
# relaxed rule is satisfied by the channel ENGINE alone.
239+
assert tg.AMP_FOR_ENGINE["wm_gs"] == "hit"
240+
assert not any(r.get("amplifier_channel") == "hit" for r in recs) # old gate
241+
assert tg._channel_consistent(recs, "wm_gs") # new rule
242+
assert any("wm_gs" in (r.get("engine_channel") or []) for r in recs)
243+
244+
# (c) end-to-end FEASIBLE: passes the skeleton team rules and the hard-edge
245+
# drop (survival present, keystones enabled, channel-consistent engine).
246+
sk = tg.build_unified_skeleton("wm_gs")
247+
ok_rules, rep = tg.team_rules_report(recs, sk, "clan_boss", is_cb=True)
248+
assert ok_rules, rep
249+
assert rep["channel_consistent"]
250+
assert rep["survival_present"] and rep["keystone_ok"]
251+
rr = sr.resolve(AMPLESS_WM_GS_STALL,
252+
sr.ResolveContext(location="clan_boss", mode="cb"))
253+
ok_edge, reason = tg._hard_edges_ok(rr, recs, "wm_gs")
254+
assert ok_edge, reason
255+
256+
# (d) CREDITING stays channel-correct: the comp is feasible WITHOUT crediting
257+
# any amplifier->engine edge (preference, not requirement).
258+
assert not tg._value_edge_present(rr, "wm_gs")
259+
260+
261+
def test_double_survival_skeleton_is_abstract_and_valid():
262+
"""The new multi-keystone stall skeleton is a TAG-PREDICATE skeleton (no
263+
champion names), has >1 survival slot, and obeys the predicate vocabulary."""
264+
for ch in tg.ENGINE_CHANNELS:
265+
sk = tg.build_double_survival_skeleton(ch)
266+
assert sk.name.startswith("double_survival")
267+
survival_slots = [s for s in sk.slots
268+
if s.name.startswith("survival")]
269+
assert len(survival_slots) >= 2, "must have >1 survival slot"
270+
for slot in sk.slots:
271+
assert slot.require_any, slot
272+
for pred in slot.require_any:
273+
assert _PRED_RE.match(pred), f"non-vocab predicate {pred!r}"
274+
for champ in CHAMPION_NAMES:
275+
assert champ.lower() not in pred.lower()
276+
# both skeleton families are emitted for a roster with engine channels.
277+
sks = tg.build_named_skeletons({"hit", "poison"})
278+
names = [s.name for s in sks]
279+
assert any(n.startswith("unified") for n in names)
280+
assert any(n.startswith("double_survival") for n in names)
281+
282+
211283
# --------------------------------------------------------------------------- #
212284
# 6. Tractability — pool=all under budget, max_candidates respected, deterministic
213285
# --------------------------------------------------------------------------- #

tools/team_generator.py

Lines changed: 112 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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).
192227
def 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
359395
def _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

369415
def 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

770856
def _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

Comments
 (0)