feat(policy): add opt-in composite reward scoring with audit tests

This commit is contained in:
0xrsydn 2026-09-23 14:17:38 +07:00
commit 32359c1fe7
5 changed files with 233 additions and 1 deletions

View file

@ -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
View 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)

View file

@ -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"]},