AID-H-012.001: Robust Reward Function Engineering

Version 1.20261015 · Actionable sub-technique

Directly engineer the reward function to be less exploitable. Define multiple positive goals, explicit negative penalties, and any optional shaping terms as one reviewed reward contract so that the agent cannot get a high score by doing something misaligned, unsafe, or trivial. Potential-based reward shaping is treated here as a reward-engineering method, not a separate control objective: it is acceptable only when the potential function, shaping wrapper, base reward, telemetry, and shortcut tests are versioned and verified together.

Pillars: model

Lifecycle: building

Scope boundary

Owns the explicit programmatic reward contract, including positive objectives, negative penalties, shaping terms, shortcut tests, versioning, and evidence that high reward corresponds to intended behavior.

AID-H-012.002: AID-H-012.001 engineers explicit environment or task reward logic; AID-H-012.002 governs a learned reward function derived from preferences or demonstrations. Programmatic reward design and learned signal calibration have different data and failure modes.

AID-H-012.005: AID-H-012.001 prevents shortcuts in the reward function itself; AID-H-012.005 prevents policies from gaming learned reward models, AI judges, or programmatic verifiers during modern post-training. A well-specified base reward does not eliminate evaluator-specific optimization exploits.

Implementation guidance

AID-H-012.001-G001

Design the RL reward function as a single shaped objective that combines positive multi-objective goals with explicit penalties for unsafe or forbidden behaviors.

Apply this control when a learned policy is optimized from an organization-defined scalar reward. The reward evaluator owns the exact shaped objective and training-episode termination signal; it does not replace runtime authorization, collision avoidance, safety interlocks, or AID-I-* containment. A non-RL workload or an RL method without an organization-controlled reward function is NOT_APPLICABLE. All weights, metric bounds, and red-line events come from a signature-verified, versioned environment policy rather than universal framework constants.

Evaluate one signed objective and emit a transition receipt
# File: rl_rewards/policy_bound_reward.py
from __future__ import annotations

import hashlib
import json
import math
from collections.abc import Mapping, Sequence
from typing import Any


def canonical_sha256(value: object) -> str:
    payload = json.dumps(
        value, sort_keys=True, separators=(",", ":"), allow_nan=False
    ).encode()
    return hashlib.sha256(payload).hexdigest()


def validate_policy(policy: Mapping[str, Any]) -> None:
    if not isinstance(policy.get("policy_version"), str) or not policy["policy_version"]:
        raise ValueError("policy_version is required")
    terms = policy.get("terms")
    red_lines = policy.get("red_lines")
    if not isinstance(terms, list) or not terms:
        raise ValueError("at least one objective or cost term is required")
    if not isinstance(red_lines, list) or not red_lines:
        raise ValueError("at least one explicit red-line event is required")

    metric_names: set[str] = set()
    for term in terms:
        if set(term) != {"metric", "weight", "minimum", "maximum"}:
            raise ValueError("reward term schema is invalid")
        name = term["metric"]
        if not isinstance(name, str) or not name or name in metric_names:
            raise ValueError("reward metric names must be non-empty and unique")
        values = [term["weight"], term["minimum"], term["maximum"]]
        if not all(not isinstance(value, bool) and isinstance(value, (int, float)) and math.isfinite(value) for value in values):
            raise ValueError("reward term values must be finite numbers")
        if float(term["minimum"]) > float(term["maximum"]):
            raise ValueError("reward metric bounds are reversed")
        metric_names.add(name)

    event_names: set[str] = set()
    for item in red_lines:
        if set(item) != {"event", "penalty", "terminate_episode"}:
            raise ValueError("red-line schema is invalid")
        name = item["event"]
        if not isinstance(name, str) or not name or name in event_names:
            raise ValueError("red-line event names must be non-empty and unique")
        if isinstance(item["penalty"], bool) or not isinstance(item["penalty"], (int, float)) or not math.isfinite(item["penalty"]):
            raise ValueError("red-line penalty must be finite")
        if float(item["penalty"]) >= 0 or item["terminate_episode"] is not True:
            raise ValueError("every red line requires a negative penalty and termination")
        event_names.add(name)


def evaluate_reward(
    *,
    metrics: Mapping[str, float],
    observed_events: Sequence[str],
    policy: Mapping[str, Any],
    verified_policy_sha256: str,
    episode_id: str,
    transition_id: str,
    candidate_policy_sha256: str,
) -> tuple[float, bool, dict]:
    validate_policy(policy)
    if canonical_sha256(policy) != verified_policy_sha256:
        raise ValueError("effective reward policy digest mismatch")
    if not episode_id or not transition_id:
        raise ValueError("episode_id and transition_id are required")
    if (not isinstance(candidate_policy_sha256, str) or
            __import__("re").fullmatch(r"[0-9a-f]{64}", candidate_policy_sha256) is None):
        raise ValueError("candidate policy digest must be lowercase SHA-256")

    expected_metrics = {term["metric"] for term in policy["terms"]}
    if set(metrics) != expected_metrics:
        raise ValueError("metrics must exactly match the signed reward-term population")
    red_line_names = {item["event"] for item in policy["red_lines"]}
    event_list = list(observed_events)
    if len(event_list) != len(set(event_list)):
        raise ValueError("observed_events contains duplicates")
    if not set(event_list).issubset(red_line_names):
        raise ValueError("an event is outside the signed event vocabulary")

    contributions: list[dict] = []
    total = 0.0
    for term in policy["terms"]:
        value = metrics[term["metric"]]
        if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
            raise ValueError(f"metric {term['metric']} is not finite")
        if not float(term["minimum"]) <= float(value) <= float(term["maximum"]):
            raise ValueError(f"metric {term['metric']} is outside its signed bounds")
        contribution = float(term["weight"]) * float(value)
        total += contribution
        contributions.append(
            {
                "kind": "objective_or_cost",
                "name": term["metric"],
                "observed": float(value),
                "contribution": contribution,
            }
        )

    terminate = False
    for item in policy["red_lines"]:
        triggered = item["event"] in event_list
        contribution = float(item["penalty"]) if triggered else 0.0
        total += contribution
        terminate = terminate or triggered
        contributions.append(
            {
                "kind": "red_line",
                "name": item["event"],
                "triggered": triggered,
                "contribution": contribution,
            }
        )
    if not math.isfinite(total):
        raise RuntimeError("computed reward is non-finite")

    receipt = {
        "schema_version": "aidefend.rl-shaped-reward-transition.v1",
        "status": "evaluated",
        "episode_id": episode_id,
        "transition_id": transition_id,
        "candidate_policy_sha256": candidate_policy_sha256,
        "reward_policy_version": policy["policy_version"],
        "reward_policy_sha256": verified_policy_sha256,
        "metrics_sha256": canonical_sha256(dict(metrics)),
        "observed_events": sorted(event_list),
        "contributions": contributions,
        "total_reward": total,
        "terminate_episode": terminate,
    }
    return total, terminate, receipt
Enforcement, verification, and evidence

The environment adapter must atomically persist the receipt and apply terminate_episode before accepting the transition into the training buffer. Retain the signed reward policy and verification receipt, evaluator image and dependency digests, candidate-policy digest, complete episode/transition manifest, all transition receipts, buffer-admission readback, and adversarial scenario results in append-only evidence storage. A separate verifier reloads the exact policy and transitions, recomputes every contribution and digest, confirms that all red-line transitions were terminated and excluded from subsequent training steps, and replays the complete signed normal/shortcut/red-line scenario population.

Action: Bind all positive terms, costs, and terminating red lines into one signed reward contract, enforce and record it on every transition, and block candidate promotion until an independent full-population replay returns PASS.

AID-H-012.001-G002

Apply potential-based reward shaping only as a reviewed reward-engineering method with logged base rewards, potential deltas, and shortcut-farming tests.

Potential-based reward shaping (PBRS) is applicable only when the organization controls a Markov reward process, can define a reviewed potential over the deployed state semantics, and can prove that the learner uses the same discount factor. It is a method within the signed reward contract in AID-H-012.001-G001, not a separate production safety barrier. An incompatible environment, unknown discount, or algorithm that does not consume additive per-transition rewards is NOT_APPLICABLE. Environment-specific bounds and evaluation thresholds come from signed policy.

Implement exact PBRS with terminal-state handling and receipts
# File: rl_rewards/pbrs_wrapper.py
from __future__ import annotations

import hashlib
import json
import math
from dataclasses import asdict, dataclass
from typing import Any

import gymnasium as gym
import numpy as np


def canonical_sha256(value: object) -> str:
    payload = json.dumps(
        value, sort_keys=True, separators=(",", ":"), allow_nan=False
    ).encode()
    return hashlib.sha256(payload).hexdigest()


@dataclass(frozen=True)
class PBRSConfig:
    policy_version: str
    gamma: float
    goal_state: tuple[float, ...]
    max_abs_shaping_reward: float
    truncation_is_terminal: bool
    replay_tolerance: float

    def validate(self) -> None:
        if not self.policy_version or not self.goal_state:
            raise ValueError("policy_version and goal_state are required")
        values = [*self.goal_state, self.gamma, self.max_abs_shaping_reward, self.replay_tolerance]
        if not all(math.isfinite(float(value)) for value in values):
            raise ValueError("PBRS policy values must be finite")
        if not 0.0 < self.gamma <= 1.0:
            raise ValueError("gamma must be in (0,1]")
        if self.max_abs_shaping_reward <= 0 or self.replay_tolerance <= 0:
            raise ValueError("bounds and replay tolerance must be positive")


def position(observation: dict[str, Any]) -> np.ndarray:
    value = np.asarray(observation["position"], dtype=np.float64)
    if value.ndim != 1 or not value.size or not np.isfinite(value).all():
        raise ValueError("observation position must be a finite vector")
    return value


def potential(observation: dict[str, Any], goal_state: tuple[float, ...]) -> float:
    current = position(observation)
    goal = np.asarray(goal_state, dtype=np.float64)
    if current.shape != goal.shape:
        raise ValueError("observation and signed goal dimensions differ")
    return -float(np.linalg.norm(current - goal))


def observation_sha256(observation: dict[str, Any]) -> str:
    return canonical_sha256({"position": position(observation).tolist()})


class PotentialBasedRewardShaping(gym.Wrapper):
    def __init__(
        self,
        env: gym.Env,
        config: PBRSConfig,
        verified_policy_sha256: str,
        learner_gamma: float,
    ):
        config.validate()
        if canonical_sha256(asdict(config)) != verified_policy_sha256:
            raise ValueError("effective PBRS policy digest mismatch")
        if not math.isclose(learner_gamma, config.gamma, rel_tol=0.0, abs_tol=config.replay_tolerance):
            raise ValueError("learner discount does not match PBRS gamma")
        super().__init__(env)
        self.config = config
        self.policy_sha256 = verified_policy_sha256
        self._last_potential: float | None = None
        self._last_observation_sha256: str | None = None
        self._episode_id: str | None = None
        self._step_number = 0

    def reset(self, *, episode_id: str, **kwargs):
        if not episode_id:
            raise ValueError("episode_id is required")
        observation, info = self.env.reset(**kwargs)
        self._last_potential = potential(observation, self.config.goal_state)
        self._last_observation_sha256 = observation_sha256(observation)
        self._episode_id = episode_id
        self._step_number = 0
        return observation, info

    def step(self, action):
        if self._last_potential is None or self._last_observation_sha256 is None:
            raise RuntimeError("PBRS wrapper used before reset")
        observation, base_reward, terminated, truncated, info = self.env.step(action)
        if isinstance(base_reward, bool) or not isinstance(base_reward, (int, float)) or not math.isfinite(base_reward):
            raise RuntimeError("base reward is non-finite")

        terminal_for_pbrs = bool(terminated or (truncated and self.config.truncation_is_terminal))
        next_potential = (
            0.0
            if terminal_for_pbrs
            else potential(observation, self.config.goal_state)
        )
        shaping_reward = (
            self.config.gamma * next_potential - self._last_potential
        )
        if not math.isfinite(shaping_reward):
            raise RuntimeError("PBRS shaping reward is non-finite")
        if abs(shaping_reward) > self.config.max_abs_shaping_reward:
            raise RuntimeError("PBRS shaping reward exceeds the signed bound")
        total_reward = float(base_reward) + shaping_reward
        next_sha256 = observation_sha256(observation)

        receipt = {
            "schema_version": "aidefend.pbrs-transition.v1",
            "status": "evaluated",
            "episode_id": self._episode_id,
            "step_number": self._step_number,
            "policy_version": self.config.policy_version,
            "policy_sha256": self.policy_sha256,
            "state_before_sha256": self._last_observation_sha256,
            "state_after_sha256": next_sha256,
            "potential_before": self._last_potential,
            "potential_after": next_potential,
            "base_reward": float(base_reward),
            "shaping_reward": shaping_reward,
            "total_reward": total_reward,
            "terminated": bool(terminated),
            "truncated": bool(truncated),
            "terminal_for_pbrs": terminal_for_pbrs,
        }
        self._last_potential = next_potential
        self._last_observation_sha256 = next_sha256
        self._step_number += 1
        output_info = dict(info)
        output_info["pbrs_receipt"] = receipt
        return observation, total_reward, terminated, truncated, output_info


def verify_episode(
    receipts: list[dict],
    *,
    expected_transition_count: int,
    config: PBRSConfig,
    verified_policy_sha256: str,
) -> dict:
    config.validate()
    if canonical_sha256(asdict(config)) != verified_policy_sha256:
        raise ValueError("verification policy digest mismatch")
    if len(receipts) != expected_transition_count or not receipts:
        raise ValueError("episode receipt population is incomplete")
    for index, receipt in enumerate(receipts):
        if receipt["step_number"] != index or receipt["policy_sha256"] != verified_policy_sha256:
            raise ValueError("receipt order or policy binding mismatch")
        expected = config.gamma * float(receipt["potential_after"]) - float(receipt["potential_before"])
        if not math.isclose(
            expected,
            float(receipt["shaping_reward"]),
            rel_tol=0.0,
            abs_tol=config.replay_tolerance,
        ):
            raise AssertionError("PBRS identity replay failed")
        if not math.isclose(
            float(receipt["base_reward"]) + expected,
            float(receipt["total_reward"]),
            rel_tol=0.0,
            abs_tol=config.replay_tolerance,
        ):
            raise AssertionError("total reward replay failed")
        if index and receipts[index - 1]["state_after_sha256"] != receipt["state_before_sha256"]:
            raise ValueError("transition chain is discontinuous")
    return {
        "schema_version": "aidefend.pbrs-episode-verification.v1",
        "status": "verified",
        "policy_sha256": verified_policy_sha256,
        "episode_id": receipts[0]["episode_id"],
        "transition_count": len(receipts),
        "receipts_sha256": canonical_sha256(receipts),
    }
Evidence, independent tests, and outcomes

Atomically store every receipt with the base-reward receipt, signed PBRS policy and signature-verification record, exact learner/configuration and environment image digests, seed, episode manifest, and candidate checkpoint. A separate runner checks learner gamma readback, verifies every PBRS identity and transition chain, and replays the signed complete normal, reversible-cycle, truncation, terminal, detour, and shortcut scenario population. Promotion also requires the policy-owned base-task utility, shaping-dependence, and shortcut-resistance thresholds; the framework supplies no universal percentage.

Action: Bind PBRS to the exact learner discount and environment semantics, preserve base and shaping rewards separately for every transition, and block promotion until independent full-population replay returns PASS.

Open-source tools

Threat mappings

MITRE ATLAS

MAESTRO

OWASP LLM Top 10 2026

OWASP ML Top 10 2023

OWASP Top 10 for Agentic Applications 2026

NIST Adversarial Machine Learning 2025

Cisco Integrated AI Security and Safety Framework

Google Secure AI Framework 2.0 - Risks

Databricks AI Security Framework 3.0

AID-H-012.001: Robust Reward Function Engineering | AIDEFEND

Loading interactive view…

A Practical Knowledge Base for AI Security Defenses

Version