feat(policy): add opt-in composite reward scoring with audit tests
This commit is contained in:
parent
5719faa05b
commit
32359c1fe7
5 changed files with 233 additions and 1 deletions
|
|
@ -12,6 +12,7 @@ class Decision:
|
|||
reason: str = ""
|
||||
source: str = "code" # code | jev | fallback
|
||||
confidence: float | None = None
|
||||
scoring: dict | None = None
|
||||
|
||||
def __str__(self) -> str:
|
||||
params = ", ".join(f"{k}={v!r}" for k, v in self.params.items())
|
||||
|
|
|
|||
127
policy/reward_scoring.py
Normal file
127
policy/reward_scoring.py
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
"""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)
|
||||
|
|
@ -52,6 +52,10 @@ def card_reward_decision(obs: dict, client: JevClient | None, deck: dict | None,
|
|||
if not cards:
|
||||
return Decision("skip_card_reward", {}, "no cards offered", "code")
|
||||
|
||||
if skip_policy == "composite":
|
||||
from .reward_scoring import composite_reward_decision
|
||||
return composite_reward_decision(obs, client, deck, best_by_rarity(cards))
|
||||
|
||||
if client is None:
|
||||
fallback_card = best_by_rarity(cards)
|
||||
return Decision("select_card_reward", {"card_index": fallback_card["index"]},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue