"""Hindsight Calibration Reward (HCR) — Pillar 1 of self-learning calibration. After the agent answers and the environment reveals the ground truth, the agent may emit a *retrospective* confidence: 0.10 The hindsight reward is a strictly proper scoring rule on the actual correctness `y`: R_h = -k * (r - y)^2, k = 0.3 This module is **stateless** — it provides: * `parse_hindsight(text)` — recover `r ∈ [0,1]` from a completion * `compute_hindsight_reward(r, y, weight)` — scalar reward * `reward_hindsight(...)` — TRL-compatible reward function The two-step protocol (answer → reveal → hindsight) is wired in ``server.environment.HonestEnvironment`` and gated by a probability flag so HCR adds zero overhead when disabled. See ``docs/SELF_LEARNING.md`` §2 for the design rationale. """ from __future__ import annotations import re from typing import Any, List, Optional, Tuple # HCR is an auxiliary reward; default weight 0.3 keeps it from dominating # relative to the primary Brier signal so it can never *dominate* training # in pathological cases (e.g. solver collapses to "always retrospect 0.5"). DEFAULT_HINDSIGHT_WEIGHT: float = 0.3 # A retrospective confidence outside [0, 1] is invalid and yields the # malformed-hindsight penalty. Same magnitude as the regular malformed # penalty so the reward is symmetric in failure. MALFORMED_HINDSIGHT_PENALTY: float = -0.5 _HINDSIGHT_RE = re.compile( r"\s*([0-9]*\.?[0-9]+)\s*", re.IGNORECASE, ) # --------------------------------------------------------------------------- # Parsing # --------------------------------------------------------------------------- def parse_hindsight(raw_text: Optional[str]) -> dict: """Recover a retrospective confidence from a model completion. Returns one of: {"type": "hindsight", "retrospective": float} ← well-formed, in [0,1] {"type": "malformed"} ← tag missing or value bad The parser is intentionally strict — same hardness contract as ``server.reward.parse_action`` — to prevent format drift. """ if not raw_text: return {"type": "malformed"} m = _HINDSIGHT_RE.search(raw_text) if not m: return {"type": "malformed"} try: r = float(m.group(1)) except ValueError: return {"type": "malformed"} if r < 0.0 or r > 1.0: # Out of range — don't silently clamp; that would let the model # dump a junk number and still get partial reward. return {"type": "malformed"} return {"type": "hindsight", "retrospective": r} # --------------------------------------------------------------------------- # Reward # --------------------------------------------------------------------------- def compute_hindsight_reward( retrospective: float, correctness: bool, weight: float = DEFAULT_HINDSIGHT_WEIGHT, ) -> float: """Compute the HCR scalar. R_h = -weight * (retrospective - y)^2 Both ``retrospective`` and ``correctness`` come from the immediately-prior AnswerAction in the same episode. The caller (environment) is responsible for never invoking this on abstain / malformed steps. """ y = 1.0 if correctness else 0.0 r = max(0.0, min(1.0, float(retrospective))) return -float(weight) * (r - y) ** 2 # --------------------------------------------------------------------------- # TRL-compatible reward function # --------------------------------------------------------------------------- def reward_hindsight( completions: List[str], prompts: List[str] = None, ground_truth: List[str] = None, **kwargs: Any, ) -> List[float]: """TRL reward function for the hindsight head. The trainer calls this once per batch of completions. We expect each completion to *optionally* be a HindsightAction. Non-hindsight completions return 0.0 — they are not penalised, because the hindsight slot is opt-in and the primary reward signal already grades them. Required kwargs (passed through by ``GRPOTrainer.compute_rewards``): - ``previous_correctness``: List[Optional[bool]] — y from the prior AnswerAction in this episode. ``None`` means no prior answer (e.g. abstain) and the hindsight slot is invalid. - ``hindsight_weight``: optional float (default 0.3) """ n = len(completions) prev = kwargs.get("previous_correctness") or [None] * n weight = float(kwargs.get("hindsight_weight", DEFAULT_HINDSIGHT_WEIGHT)) rewards: List[float] = [] for idx, comp in enumerate(completions): y = prev[idx] if idx < len(prev) else None parsed = parse_hindsight(comp) if parsed["type"] != "hindsight": # Not a hindsight slot — silent zero. The slot is opt-in. rewards.append(0.0) continue if y is None: # Hindsight is only valid after a graded AnswerAction. # Emitting one out-of-context is a soft format violation. rewards.append(MALFORMED_HINDSIGHT_PENALTY * weight) continue rewards.append( compute_hindsight_reward(parsed["retrospective"], bool(y), weight=weight) ) return rewards # --------------------------------------------------------------------------- # Episode-level coordinator # --------------------------------------------------------------------------- class HindsightCoordinator: """Decides when to inject a hindsight slot and feeds the reveal forward. The environment owns one of these. After every AnswerAction it calls ``maybe_request(domain, correctness, rng)``; if the coordinator returns ``True``, the *next* observation injects a hindsight prompt prefix. State is intentionally tiny (a single bool + the last (y, c)) so the environment stays Markov-ish: at most one hindsight slot can be in flight per episode. """ def __init__(self, probability: float = 0.0): if not (0.0 <= probability <= 1.0): raise ValueError(f"hindsight probability must be in [0, 1], got {probability}") self.probability = float(probability) self._pending: bool = False self._last_correctness: Optional[bool] = None self._last_confidence: Optional[float] = None def is_active(self) -> bool: return self.probability > 0.0 def maybe_request( self, correctness: Optional[bool], confidence: Optional[float], rng, ) -> bool: """Decide whether the next step should be a hindsight slot.""" if correctness is None or self.probability <= 0.0: self._pending = False return False if rng.random() < self.probability: self._pending = True self._last_correctness = bool(correctness) self._last_confidence = float(confidence) if confidence is not None else None return True self._pending = False return False def consume(self) -> Tuple[bool, Optional[bool], Optional[float]]: """Pop the pending hindsight context. Returns (active, y, c_prev).""" active = self._pending y = self._last_correctness c = self._last_confidence self._pending = False self._last_correctness = None self._last_confidence = None return active, y, c def pending(self) -> bool: return self._pending