127 lines
6.3 KiB
Python
127 lines
6.3 KiB
Python
"""Experimental reward-only scoring. No game calls or persistent memory."""
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
|
|
import facts as F
|
|
from jev import ScoreAnswer, gate, score
|
|
from .context import Decision
|
|
from .scoring import normalize_score, rank_candidates, weighted_utility
|
|
|
|
# Fixed experimental weights and margin, not fitted win probabilities.
|
|
WEIGHTS = {"offense": 0.35, "defense": 0.30, "scaling": 0.15, "consistency": 0.20}
|
|
TAKE_MARGIN = 0.10
|
|
RUBRICS = {
|
|
"offense": [
|
|
"Frequently delays attacks or displaces damage needed in the first shuffle.",
|
|
"Slightly reduces damage delivered in the first shuffle.",
|
|
"Does not materially change damage delivered in the first shuffle.",
|
|
"Adds usable damage in the first shuffle with support already observed.",
|
|
"Fills a major early damage gap reliably with support already observed.",
|
|
],
|
|
"defense": [
|
|
"Frequently prevents playing needed block or direct damage prevention.",
|
|
"Slightly reduces available block or direct damage prevention.",
|
|
"Does not materially change block or direct damage prevention.",
|
|
"Adds usable block or direct damage prevention with existing support.",
|
|
"Reliably fills a major block or direct damage prevention gap.",
|
|
],
|
|
"scaling": [
|
|
"Frequently disrupts existing repeated-turn damage or defense growth.",
|
|
"Slightly delays existing repeated-turn damage or defense growth.",
|
|
"Does not materially change performance as a fight continues.",
|
|
"Adds usable repeated-turn growth with support already observed.",
|
|
"Reliably fills a major long-fight growth gap with existing support.",
|
|
],
|
|
"consistency": [
|
|
"Frequently creates unplayable hands or requires missing setup and resources.",
|
|
"Adds draw dilution or resource pressure more often than useful access.",
|
|
"Does not materially change draw access, resource fit, or setup reliability.",
|
|
"Improves draw access, resource fit, or setup using existing support.",
|
|
"Reliably removes a major draw, resource, or setup bottleneck.",
|
|
],
|
|
}
|
|
|
|
|
|
def reward_context(obs: dict, deck: dict | None) -> dict:
|
|
"""Keep observed metadata intact; do not infer a permanent deck or draw order."""
|
|
player = obs.get("player") or {}
|
|
reward = obs.get("card_reward") or {}
|
|
return {
|
|
"run": obs.get("run") or {},
|
|
"player": {k: player.get(k) for k in
|
|
("character", "hp", "max_hp", "gold", "relics", "potions")},
|
|
"card_evidence": F.deck_context(deck),
|
|
"offered": {f"card{c['index']}": dict(c) for c in reward.get("cards", [])},
|
|
"can_skip": reward.get("can_skip"),
|
|
"limits": ["Permanent deck is unavailable; combat piles are limited evidence.",
|
|
"Generated cards and temporary upgrades may occur in that evidence.",
|
|
"Pile order is not draw order. Unobserved support is unknown.",
|
|
"Skip side effects and future encounters are unknown."],
|
|
}
|
|
|
|
|
|
def composite_reward_decision(obs, client, deck, fallback_card) -> Decision:
|
|
state = reward_context(obs, deck)
|
|
audit = {"policy": "composite-v1", "weights": dict(WEIGHTS),
|
|
"take_margin": TAKE_MARGIN, "components": {},
|
|
"evidence": state["card_evidence"],
|
|
"skip_baseline": "unknown"}
|
|
|
|
def fallback(reason, selected=None):
|
|
audit["gate_reason"] = reason
|
|
selected = fallback_card if selected is None else selected
|
|
return Decision("select_card_reward", {"card_index": selected["index"]},
|
|
f"composite fallback: {reason}; selected {selected['index']}", "fallback",
|
|
scoring=audit)
|
|
|
|
if client is None:
|
|
return fallback("no model client")
|
|
if state["card_evidence"] == "unknown":
|
|
return fallback("missing scoped card evidence")
|
|
|
|
questions = {}
|
|
for key in state["offered"]:
|
|
for axis, rubric in RUBRICS.items():
|
|
questions[f"{key}_{axis}"] = score(
|
|
f"Estimate the marginal {axis} effect of adding offered.{key} versus "
|
|
"leaving the deck unchanged. Use only observed support. Consider cost, "
|
|
"effect text, duplicates, character mechanics, and relic interactions. "
|
|
"Rarity alone is not value. Do not assume future upgrades or pickups. "
|
|
"Card pairings matter only when their required support is observed. "
|
|
"Do not count faster kills as defense. Do not count synergy as a separate bonus. "
|
|
"Use the supplied axis rubric; confidence is not correctness.", rubric)
|
|
response = client.ask(state, questions)
|
|
usable = True
|
|
for key in state["offered"]:
|
|
components = {}
|
|
for axis in WEIGHTS:
|
|
answer = response.get(f"{key}_{axis}")
|
|
valid = (isinstance(answer, ScoreAnswer)
|
|
and math.isfinite(answer.score) and 0 <= answer.score <= 4
|
|
and len(answer.legend) == 5 and gate(answer))
|
|
components[axis] = {"score": answer.score if isinstance(answer, ScoreAnswer) and math.isfinite(answer.score) else None,
|
|
"accepted": bool(valid)}
|
|
usable = usable and valid
|
|
audit["components"][key] = components
|
|
if not usable:
|
|
return fallback("missing, invalid, or uncertain component")
|
|
|
|
utilities = {
|
|
key: weighted_utility(
|
|
{axis: normalize_score(value["score"], 0, 2, 4)
|
|
for axis, value in components.items()},
|
|
WEIGHTS,
|
|
)
|
|
for key, components in audit["components"].items()
|
|
}
|
|
audit["utilities"] = utilities
|
|
best = rank_candidates(utilities)[0]
|
|
# The current serializer cannot prove that an alternative is a plain skip.
|
|
# Do not silently assign that alternative zero utility.
|
|
if utilities[best] <= TAKE_MARGIN and state["can_skip"] is not False:
|
|
return fallback("no clear addition; skip effects unresolved", state["offered"][best])
|
|
audit["gate_reason"] = "positive addition" if utilities[best] > TAKE_MARGIN else "forced selection"
|
|
return Decision("select_card_reward", {"card_index": state["offered"][best]["index"]},
|
|
f"composite chose {best}; utility={utilities[best]:.3f}", "jev",
|
|
scoring=audit)
|