"""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)