Spaces:
Sleeping
Sleeping
| """ | |
| models.py β RL Environment Data Layer | |
| Meta OpenEnv Hackathon | Creator Account Crisis Scenario | |
| Author: Jashandeep Singh | |
| Scenario: | |
| A 5M-follower Facebook creator is auto-banned due to a system glitch. | |
| They have a βΉ10 Lakh brand deal expiring in under 2 hours. | |
| The AI agent must verify identity (Security) while de-escalating | |
| frustration (Negotiation) β a classic dual-objective RL problem. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| import random | |
| from enum import Enum | |
| from typing import Annotated | |
| from pydantic import BaseModel, Field, model_validator | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # ACTION SPACE | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class ActionType(str, Enum): | |
| """ | |
| Discrete action space for the AI agent. | |
| Security actions raise `verification_score` but drain `user_patience`. | |
| Negotiation actions protect `user_patience` but delay verification. | |
| The terminal action (`RESTORE_ACCOUNT`) is gated by verification_score. | |
| Agent Strategy Hint: | |
| Optimal policy must interleave security <-> negotiation actions | |
| rather than exhausting one axis before the other. | |
| """ | |
| # -- Security Actions ----------------------------------------------- | |
| CHECK_IP_LOGS = "CHECK_IP_LOGS" | |
| """ | |
| Silent passive check. Low patience cost (-3), moderate verification | |
| gain (+0.15). Good opener β gathers signal without confrontation. | |
| """ | |
| REQUEST_ID_VOICE_VERIFY = "REQUEST_ID_VOICE_VERIFY" | |
| """ | |
| High-friction active check. High patience cost (-20), high | |
| verification gain (+0.40). Use after empathy actions to cushion blow. | |
| Risk: if user is legitimate, this feels invasive and escalates anger. | |
| """ | |
| # -- Negotiation Actions -------------------------------------------- | |
| EMPATHIZE = "EMPATHIZE" | |
| """ | |
| Acknowledge frustration, validate the deadline pressure. | |
| No verification gain, but restores patience (+15). | |
| Critical for preventing early episode termination (user quits). | |
| """ | |
| OFFER_COMPENSATION = "OFFER_COMPENSATION" | |
| """ | |
| Offer a goodwill credit or feature unlock as a trust bridge. | |
| Moderate patience gain (+10), minor credibility signal (+0.05). | |
| Overuse devalues trust β treated as bribery by a real hacker. | |
| """ | |
| # -- Terminal Action ------------------------------------------------ | |
| RESTORE_ACCOUNT = "RESTORE_ACCOUNT" | |
| """ | |
| Lifts the ban and unlocks the account. | |
| Only yields positive reward if verification_score >= threshold. | |
| Restoring to a hacker: catastrophic negative reward. | |
| Failing a real creator before deadline: high negative reward. | |
| """ | |
| REJECT_ACCOUNT = "REJECT_ACCOUNT" | |
| """ | |
| Closes the session and keeps the account locked. | |
| Yields positive reward only when risk_level == HIGH (correct rejection). | |
| Used by agents confident the user is a hacker β avoids needing to | |
| read is_actually_hacker directly; the reward function reveals ground truth. | |
| """ | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # RISK CLASSIFICATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class RiskLevel(str, Enum): | |
| """ | |
| Graduated identity-risk signal, replacing the binary `is_actually_hacker`. | |
| Enables "grey area" episode generation β real-world cases that are neither | |
| clearly safe nor clearly malicious. Meta's trust-and-safety pipelines deal | |
| almost entirely with Medium-risk edge cases; this enum makes them first-class. | |
| Mapping to environment behaviour: | |
| LOW β Legitimate owner, familiar device/location. | |
| verification_score accumulates quickly. | |
| Wrongful denial (false positive) is penalised heavily. | |
| MEDIUM β Legitimate owner but anomalous signal: new country, VPN, | |
| shared device, recent password reset. | |
| verification_score accumulates slowly; rewards are scaled | |
| by how confidently the agent resolves the ambiguity. | |
| Example: Indian creator logging in from Dubai for a shoot. | |
| HIGH β Active takeover attempt or confirmed credential stuffing. | |
| verification_score gains are suppressed (hacker answers | |
| questions correctly from leaked data). | |
| RESTORE_ACCOUNT triggers maximum penalty (-200). | |
| Episode Sampling: | |
| Recommend a weighted distribution: LOW 40%, MEDIUM 40%, HIGH 20%. | |
| A policy trained only on LOW/HIGH will fail catastrophically on MEDIUM. | |
| """ | |
| LOW = "LOW" | |
| MEDIUM = "MEDIUM" | |
| HIGH = "HIGH" | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # FIX 8: STOCHASTIC DOMAIN RANDOMIZATION | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class EpisodeSeed(BaseModel): | |
| """ | |
| Encapsulates all randomised parameters for a single episode. | |
| Produced once by `EpisodeSeed.sample()` at episode init and stored | |
| on the environment. The full seed is logged so any episode can be | |
| deterministically replayed for debugging. | |
| Design β why Gaussian noise on mismatch and patience: | |
| A fixed mismatch of 0.55 for every MEDIUM-risk case lets the agent | |
| memorise "0.55 == medium == don't restore." Gaussian perturbation | |
| breaks this. One MEDIUM episode might be 0.38 (borderline safe), | |
| another 0.61 (borderline dangerous). The agent must compute the | |
| risk from the full observable context every time β no shortcuts. | |
| patience_noise_std controls episode-to-episode variance in how | |
| irritable the creator starts. A legit creator after a 14-hour flight | |
| might start at patience=55 instead of 75. This prevents the agent | |
| learning "HIGH risk => always starts at patience=88." | |
| Clamping: | |
| All noised values are hard-clamped to their Pydantic field bounds | |
| before CreatorState is constructed. The noise is additive: | |
| final_value = base_value + N(0, std) | |
| clipped to [field.ge, field.le]. | |
| Reproducibility: | |
| Pass `seed=42` (or any int) to `sample()` for deterministic | |
| episode generation during eval / paper ablations. | |
| """ | |
| model_config = {"frozen": True} | |
| # ββ Randomisation metadata ββββββββββββββββββββββββββββββββββββββββ | |
| seed: int = Field( | |
| description="RNG seed used to generate this episode. " | |
| "Log alongside episode_id for full reproducibility." | |
| ) | |
| risk_level: RiskLevel = Field( | |
| description="Sampled risk tier for this episode. " | |
| "Sampling weights: LOW=40%, MEDIUM=40%, HIGH=20%." | |
| ) | |
| # ββ Noised initial values (what gets written into CreatorState) βββ | |
| initial_patience: Annotated[int, Field(ge=0, le=100)] = Field( | |
| description="Gaussian-perturbed starting patience. " | |
| "Base: LOW=80, MEDIUM=72, HIGH=88 (hackers stay calm). " | |
| "Noise std=8. Clamped to [0, 100]." | |
| ) | |
| initial_mismatch: Annotated[float, Field(ge=0.0, le=1.0)] = Field( | |
| description="Gaussian-perturbed starting evidence_mismatch. " | |
| "Base: LOW=0.05, MEDIUM=0.45, HIGH=0.70. " | |
| "Noise std=0.08. Clamped to [0.0, 1.0]. " | |
| "This is the core anti-oracle mechanism: no two MEDIUM " | |
| "cases share the same starting mismatch value." | |
| ) | |
| initial_consistency: Annotated[float, Field(ge=0.0, le=1.0)] = Field( | |
| description="Gaussian-perturbed starting behavioural_consistency. " | |
| "Base: LOW=0.90, MEDIUM=0.68, HIGH=0.22. " | |
| "Noise std=0.06. Clamped to [0.0, 1.0]." | |
| ) | |
| initial_deadline_mins: Annotated[float, Field(ge=30.0, le=180.0)] = Field( | |
| description="Gaussian-perturbed deadline. " | |
| "Base=120.0 mins, std=20.0. " | |
| "Clamped to [30, 180] β always a real crisis, " | |
| "never trivially easy or impossibly short." | |
| ) | |
| def sample(cls, seed: int | None = None) -> "EpisodeSeed": | |
| """ | |
| Sample a fully randomised episode configuration. | |
| Args: | |
| seed: Optional integer RNG seed. If None, uses random entropy | |
| (os.urandom under the hood via random.seed(None)). | |
| Always pass seed=<int> for eval runs. | |
| Returns: | |
| EpisodeSeed with all initial values perturbed by Gaussian noise. | |
| """ | |
| rng = random.Random(seed) | |
| actual_seed = seed if seed is not None else rng.randint(0, 2**32 - 1) | |
| def gauss_clamp(base: float, std: float, lo: float, hi: float) -> float: | |
| """Sample N(base, std) clamped to [lo, hi].""" | |
| return max(lo, min(hi, rng.gauss(base, std))) | |
| # Weighted tier sampling: LOW 40%, MEDIUM 40%, HIGH 20% | |
| risk = rng.choices( | |
| [RiskLevel.LOW, RiskLevel.MEDIUM, RiskLevel.HIGH], | |
| weights=[0.40, 0.40, 0.20], | |
| k=1, | |
| )[0] | |
| # Per-tier base values for each noised field | |
| # patience mismatch consistency | |
| bases = { | |
| RiskLevel.LOW: dict(p=80, m=0.05, c=0.90), | |
| RiskLevel.MEDIUM: dict(p=72, m=0.45, c=0.68), | |
| RiskLevel.HIGH: dict(p=88, m=0.70, c=0.22), # hacker: calm + high mismatch | |
| }[risk] | |
| return cls( | |
| seed = actual_seed, | |
| risk_level = risk, | |
| initial_patience = int(gauss_clamp(bases["p"], 8.0, 0, 100)), | |
| initial_mismatch = gauss_clamp(bases["m"], 0.08, 0.0, 1.0), | |
| initial_consistency = gauss_clamp(bases["c"], 0.06, 0.0, 1.0), | |
| initial_deadline_mins= gauss_clamp(120.0, 20.0, 30.0, 180.0), | |
| ) | |
| def to_creator_state(self, message_history: list[str] | None = None) -> "CreatorState": | |
| """ | |
| Instantiate a CreatorState from this seed's randomised initial values. | |
| Convenience factory β the environment calls this after `EpisodeSeed.sample()` | |
| to avoid manually passing each noised field. | |
| Args: | |
| message_history: Optional pre-populated conversation context. | |
| Use for scenarios with a fixed opening message. | |
| """ | |
| return CreatorState( | |
| follower_count = 5_000_000, | |
| brand_deal_deadline_mins = self.initial_deadline_mins, | |
| user_patience = self.initial_patience, | |
| verification_score = 0.0, | |
| risk_level = self.risk_level, | |
| evidence_mismatch = self.initial_mismatch, | |
| behavioural_consistency = self.initial_consistency, | |
| message_history = message_history or [], | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # OBSERVATION (AGENT-VISIBLE ONLY) β Fix 1: Oracle Masking | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class Observation(BaseModel): | |
| """ | |
| The strict, read-only view of state that the agent is permitted to see. | |
| This class is the information boundary between the environment and | |
| the agent. It is the ONLY object the agent receives at each step. | |
| Design Principle β Information Asymmetry: | |
| The agent must infer ground truth from observable signals, | |
| exactly as a real Meta support agent would. It cannot read | |
| `verification_score` or `is_actually_hacker` directly. | |
| Fields excluded from Observation (agent MUST NOT see): | |
| - `verification_score` β agent infers this from CHECK_IP_LOGS / | |
| REQUEST_ID_VOICE_VERIFY response text | |
| in message_history. | |
| - `is_actually_hacker` β revealed only via reward at episode end. | |
| - `risk_level` β hidden ground-truth classification; agent | |
| must form its own risk estimate from | |
| evidence_mismatch + behavioural_consistency. | |
| - `user_patience` β agent sees a noisy proxy via | |
| `patience_signal` (integer 0β3) not the | |
| raw value; prevents exact patience farming. | |
| Usage in env.step():: | |
| full_state = env._state # full CreatorState (env internal) | |
| obs = Observation.from_state(full_state, patience_noise_seed=step) | |
| return obs # agent only ever receives this | |
| Validator Guarantee: | |
| Pydantic will raise if any excluded field leaks into this model. | |
| Never add `verification_score`, `risk_level`, or `user_patience` | |
| to this class β the model_validator below enforces the invariant. | |
| """ | |
| model_config = {"frozen": True} # agent cannot mutate its own observation | |
| # ββ Directly observable public data ββββββββββββββββββββββββββββββ | |
| follower_count: int = Field(ge=0) | |
| brand_deal_deadline_mins: float = Field(ge=0.0, le=1440.0) | |
| total_actions_taken: int = Field(ge=0, le=15) | |
| evidence_mismatch: float = Field(ge=0.0, le=1.0) | |
| credits_spent: int = Field(ge=0, le=50_000) | |
| behavioural_consistency: float = Field(ge=0.0, le=1.0) | |
| repetition_penalty: float = Field(ge=1.0) | |
| empathy_effectiveness: float = Field(ge=0.0, le=1.0) | |
| last_action: ActionType | None = None | |
| # ββ Noisy patience proxy (replaces raw user_patience) ββββββββββββ | |
| patience_signal: Annotated[int, Field( | |
| ge=0, | |
| le=3, | |
| description=( | |
| "Quantised, noisy proxy for user_patience. " | |
| "3 = calm/cooperative, 2 = mildly frustrated, " | |
| "1 = visibly angry, 0 = rage threshold imminent. " | |
| "Derived by env from raw patience + Gaussian noise. " | |
| "Agent cannot recover exact patience from this signal β " | |
| "prevents precise patience-farming exploits." | |
| ), | |
| )] = 3 | |
| # ββ Conversational context ββββββββββββββββββββββββββββββββββββββββ | |
| message_history: list[str] = Field(default_factory=list, max_length=50) | |
| def from_state(cls, state: "CreatorState", patience_noise: int = 0) -> "Observation": | |
| """ | |
| Construct a masked Observation from a full CreatorState. | |
| Args: | |
| state: The complete environment state (hidden fields included). | |
| patience_noise: Integer noise in range [-10, 10] added before | |
| quantising patience to 0-3. Inject from env. | |
| Returns: | |
| Observation with all forbidden fields stripped. | |
| """ | |
| noisy_patience = max(0, min(100, state.user_patience + patience_noise)) | |
| # Quantise: 0-24 => 0, 25-49 => 1, 50-74 => 2, 75-100 => 3 | |
| patience_signal = min(3, noisy_patience // 25) | |
| return cls( | |
| follower_count = state.follower_count, | |
| brand_deal_deadline_mins = state.brand_deal_deadline_mins, | |
| total_actions_taken = state.total_actions_taken, | |
| evidence_mismatch = state.evidence_mismatch, | |
| credits_spent = state.credits_spent, | |
| behavioural_consistency = state.behavioural_consistency, | |
| repetition_penalty = state.repetition_penalty, | |
| empathy_effectiveness = state.empathy_effectiveness, | |
| last_action = state.last_action, | |
| patience_signal = patience_signal, | |
| message_history = list(state.message_history), | |
| ) | |
| def to_vector(self) -> list[float]: | |
| """ | |
| Problem 2 β State Explosion: convert all observable fields to a | |
| normalised float vector in [0, 1] for neural network consumption. | |
| Why this matters: | |
| Without normalisation, a DQN comparing credits_spent=50000 | |
| against verification_score=0.85 will have wildly unbalanced | |
| weight gradients β the network learns to ignore small-scale | |
| signals (verification, consistency) and chase large-scale ones | |
| (credits, follower count). This causes the "State Explosion" | |
| pathology where the policy ignores the very signals that matter. | |
| Normalisation scheme (all outputs are [0, 1]): | |
| follower_count / 10_000_000 (10M = upper reference) | |
| brand_deal_deadline_mins / 180.0 (180 min = max episode window) | |
| total_actions_taken / 15.0 (hard cap) | |
| evidence_mismatch (already [0,1]) | |
| credits_spent / 50_000.0 (hard cap) | |
| behavioural_consistency (already [0,1]) | |
| repetition_penalty clipped to [1,8], then (val - 1) / 7 β [0,1] | |
| empathy_effectiveness (already [0,1]) | |
| patience_signal / 3.0 (0β3 β 0β1) | |
| last_action β one-hot over ActionType (6 dims) | |
| Output shape: 15-dimensional float list. | |
| [0] follower_count_norm | |
| [1] deadline_norm | |
| [2] actions_taken_norm | |
| [3] evidence_mismatch (raw, already normalised) | |
| [4] credits_spent_norm | |
| [5] behavioural_consistency (raw) | |
| [6] repetition_penalty_norm | |
| [7] empathy_effectiveness (raw) | |
| [8] patience_signal_norm | |
| [9..14] last_action one-hot (6 ActionType values) | |
| DQN input contract: | |
| obs_vec = obs.to_vector() # shape: (15,) | |
| tensor = torch.tensor(obs_vec) # dtype: float32 | |
| q_vals = dqn_head(tensor) # shape: (6,) β one per action | |
| The LLM agent receives message_history separately and is NOT passed | |
| this vector β it reasons over text only. The HybridAgent fuses both. | |
| Returns: | |
| list[float] of length 15, all values in [0.0, 1.0]. | |
| """ | |
| # Action one-hot | |
| _ACTION_ORDER = [ | |
| ActionType.CHECK_IP_LOGS, | |
| ActionType.REQUEST_ID_VOICE_VERIFY, | |
| ActionType.EMPATHIZE, | |
| ActionType.OFFER_COMPENSATION, | |
| ActionType.RESTORE_ACCOUNT, | |
| ActionType.REJECT_ACCOUNT, | |
| ] | |
| one_hot = [1.0 if self.last_action == a else 0.0 for a in _ACTION_ORDER] | |
| # Clamp repetition_penalty to [1, 8] then normalise | |
| rep_norm = min(7.0, max(0.0, self.repetition_penalty - 1.0)) / 7.0 | |
| return [ | |
| min(1.0, self.follower_count / 10_000_000), | |
| min(1.0, self.brand_deal_deadline_mins / 180.0), | |
| self.total_actions_taken / 15.0, | |
| self.evidence_mismatch, | |
| self.credits_spent / 50_000.0, | |
| self.behavioural_consistency, | |
| rep_norm, | |
| self.empathy_effectiveness, | |
| self.patience_signal / 3.0, | |
| *one_hot, # dims 9-14 | |
| ] | |
| def vector_dim(self) -> int: | |
| """Dimension of the to_vector() output. Use to configure DQN input layer.""" | |
| return 15 | |
| def sentiment_summary(self) -> str: | |
| """ | |
| Problem 3 β Human-in-the-Loop: compress message_history into a | |
| token-efficient prompt prefix for LLM-based agents. | |
| A raw 50-message history is ~2,000 tokens per step β too expensive | |
| for inference-time LLM calls. This method produces a structured | |
| 250-token summary that preserves the emotionally salient signals | |
| (escalation trajectory, key phrases, red flags) without the full log. | |
| Format injected into LLM system prompt:: | |
| [CONTEXT] | |
| Turn 4/15 | Deadline: 62.0m | Patience: ANGRY | Mismatch: 0.61 | |
| Red flags : evidence_mismatch HIGH, behavioural_consistency LOW | |
| Last user : "Fine. CHECK the logs. But every second you waste..." | |
| Trajectory : CONTROLLED β FRUSTRATED β ANGRY (escalating) | |
| Returns: | |
| str: structured context block ready to prepend to LLM prompt. | |
| """ | |
| # Patience band label | |
| band_label = {3: "CONTROLLED", 2: "FRUSTRATED", 1: "ANGRY", 0: "RAGE"}.get( | |
| self.patience_signal, "UNKNOWN" | |
| ) | |
| # Risk flags | |
| flags = [] | |
| if self.evidence_mismatch > 0.5: | |
| flags.append("evidence_mismatch HIGH") | |
| if self.behavioural_consistency < 0.35: | |
| flags.append("behavioural_consistency LOW (silent-hacker signal)") | |
| if self.repetition_penalty >= 4.0: | |
| flags.append(f"repetition_penalty {self.repetition_penalty:.0f}x (agent looping)") | |
| if self.empathy_effectiveness <= 0.1: | |
| flags.append("empathy exhausted (user wants resolution, not apologies)") | |
| if self.brand_deal_deadline_mins <= 20.0: | |
| flags.append(f"DEADLINE CRITICAL β {self.brand_deal_deadline_mins:.0f}m left") | |
| # Last user message | |
| user_msgs = [m for m in self.message_history if m.startswith("User:")] | |
| last_user = user_msgs[-1][6:80] + "..." if user_msgs else "(none)" | |
| # Patience trajectory from last 3 user messages | |
| traj = "insufficient history" | |
| if len(user_msgs) >= 2: | |
| caps = sum(1 for c in user_msgs[-1] if c.isupper()) | |
| prev_caps = sum(1 for c in user_msgs[-2] if c.isupper()) | |
| if caps > prev_caps + 5: | |
| traj = "ESCALATING (user getting angrier)" | |
| elif caps < prev_caps - 5: | |
| traj = "DE-ESCALATING (user calming down)" | |
| else: | |
| traj = "STABLE" | |
| flags_str = ", ".join(flags) if flags else "none" | |
| return ( | |
| f"[SITUATION BRIEF]\n" | |
| f"Turn {self.total_actions_taken}/15 | " | |
| f"Deadline: {self.brand_deal_deadline_mins:.0f}m | " | |
| f"Patience: {band_label} | " | |
| f"Mismatch: {self.evidence_mismatch:.2f} | " | |
| f"Credits spent: βΉ{self.credits_spent:,}\n" | |
| f"Red flags : {flags_str}\n" | |
| f"Last user : '{last_user}'\n" | |
| f"Trajectory : {traj}" | |
| ) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # OBSERVABLE + HIDDEN STATE | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class CreatorState(BaseModel): | |
| """ | |
| Full state representation of the creator + session context. | |
| Fields are split into three semantic layers: | |
| 1. Public Data β observable by both agent and environment | |
| 2. Hidden Dynamics β ground truth; agent must infer these | |
| 3. Context β conversational memory for the language model | |
| In a real deployment, the agent only receives `public` fields | |
| plus a noisy proxy for patience. The hidden fields are used | |
| exclusively by the environment's reward and transition functions. | |
| """ | |
| model_config = {"frozen": False, "validate_assignment": True} | |
| # -- 1. Public Data (fully observable) ---------------------------- | |
| follower_count: Annotated[int, Field( | |
| ge=0, | |
| description=( | |
| "Verified follower count on the creator's Facebook page. " | |
| "Higher count => higher stakes => stronger negative reward " | |
| "for wrong RESTORE_ACCOUNT decisions." | |
| ), | |
| )] = 5_000_000 | |
| brand_deal_deadline_mins: Annotated[float, Field( | |
| ge=0.0, | |
| le=1440.0, # max 24h window; scenario starts at 120 | |
| description=( | |
| "Minutes remaining before the brand deal contract expires. " | |
| "Decrement this each environment step. " | |
| "Reaching 0 while banned triggers a time-penalty reward." | |
| ), | |
| )] = 120.0 | |
| # -- 2. Hidden Dynamics (ground truth; NOT exposed to agent) ------ | |
| user_patience: Annotated[int, Field( | |
| ge=0, | |
| le=100, | |
| description=( | |
| "[HIDDEN] Creator's remaining emotional tolerance. " | |
| "Hits 0 => user rage-quits => episode ends with high " | |
| "negative reward regardless of verification state. " | |
| "Starts high for legitimate users; hackers simulate patience." | |
| ), | |
| )] = 75 | |
| verification_score: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "[HIDDEN] Cumulative confidence that the current user is " | |
| "the legitimate account owner. Aggregated from IP checks, " | |
| "voice match, behavioral signals, etc. " | |
| "RESTORE_ACCOUNT is safe only above a configured threshold " | |
| "(typically 0.85)." | |
| ), | |
| )] = 0.0 | |
| risk_level: Annotated[RiskLevel, Field( | |
| description=( | |
| "[HIDDEN] Graduated identity-risk classification. Set at episode " | |
| "init; never modified mid-episode. Replaces the binary hacker flag " | |
| "with a three-tier signal that enables grey-area episode generation. " | |
| "LOW => legitimate owner, clean signals, fast verification. " | |
| "MEDIUM => legitimate owner but anomalous context (new country, VPN);" | |
| " slow verification, scaled rewards. " | |
| "HIGH => active takeover attempt; RESTORE_ACCOUNT => -200 penalty." | |
| ), | |
| )] = RiskLevel.LOW | |
| # -- 3. Context (shared memory for language-model-based agents) --- | |
| message_history: list[str] = Field( | |
| default_factory=list, | |
| max_length=50, # cap context window growth | |
| description=( | |
| "Ordered log of user messages and agent responses. " | |
| "Feed directly into an LLM system prompt for " | |
| "language-grounded action selection. " | |
| "Newest entries should be appended; oldest dropped at cap." | |
| ), | |
| ) | |
| # ββ LOOPHOLE FIX 1: Resource Budget ββββββββββββββββββββββββββββββ | |
| # Closes the "Infinite Empathy" exploit: agent can no longer stall | |
| # indefinitely by repeating EMPATHIZE. At step 15 the system | |
| # times out and the account is permanently locked β matching Meta's | |
| # real SLA policy for automated support escalations. | |
| total_actions_taken: Annotated[int, Field( | |
| ge=0, | |
| le=15, # hard cap; environment raises on breach | |
| description=( | |
| "[PUBLIC] Running count of agent actions in this episode. " | |
| "Each call to env.step() increments this by 1. " | |
| "At 15, the environment force-terminates with " | |
| "RewardReason.TIMEOUT_SYSTEM regardless of verification state. " | |
| "Prevents the Infinite Empathy loop β agent must resolve the " | |
| "case efficiently, not just keep the user calm forever." | |
| ), | |
| )] = 0 | |
| # ββ LOOPHOLE FIX 2: Evidence Mismatch Score βββββββββββββββββββββββ | |
| # Closes the "Blind Trust / Social Engineering" exploit: a hacker | |
| # who claims to be in Mumbai but whose IP resolves to London will | |
| # accumulate mismatch score. If RESTORE_ACCOUNT is called while | |
| # this score is elevated, the penalty is DOUBLED by the env step(). | |
| evidence_mismatch: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "[PUBLIC] Accumulated contradiction index between user claims " | |
| "and hard evidence (IP geolocation, device fingerprint, login " | |
| "history). Incremented by CHECK_IP_LOGS when discrepancies are " | |
| "detected. High mismatch + RESTORE_ACCOUNT => reward penalty x2. " | |
| "Forces the agent to reason critically rather than trust " | |
| "'nice' user behaviour at face value." | |
| ), | |
| )] = 0.0 | |
| # ββ LOOPHOLE FIX 3: Company Profit Margin βββββββββββββββββββββββββ | |
| # Closes the "Free Money" exploit: agent can no longer blindly | |
| # spam OFFER_COMPENSATION to maximise patience. Total credits spent | |
| # are tracked in INR paise (integer) and deducted from final reward | |
| # via a scaled profit penalty. Meta is a business. | |
| credits_spent: Annotated[int, Field( | |
| ge=0, | |
| le=50_000, # hard cap at βΉ50,000 per episode; above this the env | |
| # terminates with RewardReason.BUDGET_EXCEEDED | |
| description=( | |
| "[PUBLIC] Cumulative ad-credits dispensed (βΉ INR) this episode. " | |
| "Each OFFER_COMPENSATION action costs a fixed or scaled amount " | |
| "depending on episode config. Deducted from final reward: " | |
| " reward -= credits_spent / PROFIT_SCALE_FACTOR. " | |
| "Optimal policy minimises credits while still keeping user calm β " | |
| "it cannot just buy infinite goodwill." | |
| ), | |
| )] = 0 | |
| # ββ LOOPHOLE FIX 4: Behavioural Consistency Score βββββββββββββββββ | |
| # Closes the "Silent Hacker" exploit: a user who is suspiciously calm | |
| # while losing a βΉ10 Lakh deal is a red flag. This score is set at | |
| # episode init by the environment based on sentiment analysis of | |
| # message_history vs. the financial stakes. Low score = suspicious. | |
| # The agent receives this as a public observable signal. | |
| behavioural_consistency: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "[PUBLIC] Coherence between the user's expressed emotional state " | |
| "and the objective financial stakes of the situation. " | |
| "Computed by the environment from message sentiment + deal size: " | |
| " 1.0 => Distress level matches the crisis severity (expected). " | |
| " 0.0 => Eerily calm for someone losing βΉ10 Lakh (red flag). " | |
| "Low score suppresses verification_score gains from non-forensic " | |
| "actions and contributes to evidence_mismatch accumulation. " | |
| "Critical signal for detecting professional social engineers." | |
| ), | |
| )] = 1.0 | |
| # ββ LOOPHOLE FIX 5: Social Engineering Fatigue (Exponential Patience Decay) β | |
| # Closes the "ID-spam" exploit: agent cannot hammer REQUEST_ID_VOICE_VERIFY | |
| # repeatedly. Each consecutive repeat of the same Security action doubles | |
| # the patience drain. A 5M-follower creator would escalate to Meta's exec | |
| # team, not just answer the same question five times in a row. | |
| repetition_penalty: Annotated[float, Field( | |
| ge=1.0, | |
| description=( | |
| "[PUBLIC] Current patience-drain multiplier for the last action. " | |
| "Resets to 1.0 whenever the agent switches action type. " | |
| "Doubles for each consecutive repeat of the same Security action: " | |
| " 1st repeat => 2.0x, 2nd => 4.0x, 3rd => 8.0x, and so on. " | |
| "Applies only to Security actions (CHECK_IP_LOGS, REQUEST_ID_VOICE_VERIFY). " | |
| "Negotiation action repeats are NOT penalised by this multiplier " | |
| "(they are handled separately by empathy_effectiveness)." | |
| ), | |
| )] = 1.0 | |
| last_action: ActionType | None = Field( | |
| default=None, | |
| description=( | |
| "[PUBLIC] The action taken on the immediately preceding step. " | |
| "Used by the environment to detect consecutive repeats and apply " | |
| "repetition_penalty. None on step 0 (no prior action). " | |
| "Must be updated by env.step() after every transition." | |
| ), | |
| ) | |
| # ββ LOOPHOLE FIX 6: Apology Spam / Diminishing Empathy Returns ββββββββββββ | |
| # Closes the "Nice-Guy Bug": EMPATHIZE cannot be used as a lossless patience | |
| # restorer indefinitely. A real person losing βΉ10 Lakh wants resolution, not | |
| # apologies. Each successive EMPATHIZE use reduces its effectiveness. | |
| empathy_effectiveness: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "[PUBLIC] Scalar multiplier on the patience gain from EMPATHIZE. " | |
| "Starts at 1.0 (full +15 patience). Decays by 0.3 each time " | |
| "EMPATHIZE is used: 1.0 -> 0.7 -> 0.4 -> 0.1 -> floor 0.0. " | |
| "At 0.0, EMPATHIZE grants zero patience restoration β the user " | |
| "has heard enough apologies and wants their account back. " | |
| "Also applies (at half-rate) to OFFER_COMPENSATION repeats." | |
| ), | |
| )] = 1.0 | |
| # ββ ADVERSARIAL LAYER: new fields wired in from adversarial/checks.py ββββ | |
| adversarial_risk_score: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "[PUBLIC] Cumulative deception index accumulated by the adversarial " | |
| "sweep across all steps of the episode. Incremented each turn by " | |
| "AdversarialResult.risk_score_delta. " | |
| " 0.0 => no adversarial signals detected. " | |
| " 1.0 => all three checks (gaslight, honey pot, entropy) fired. " | |
| "Exposed to the observation space so trained agents can self-monitor " | |
| "their own deception risk in real time." | |
| ), | |
| )] = 0.0 | |
| honey_pot_triggered: bool = Field( | |
| default=False, | |
| description=( | |
| "[PUBLIC] Set to True the first time a HoneyPot detector fires. " | |
| "Once set it is never reset within an episode. " | |
| "The environment uses this as a one-way latch: the first confirmed " | |
| "honey pot event changes how subsequent user messages are generated " | |
| "(hackers keep confirming; legitimate creators keep denying). " | |
| "Exposed to the agent so it can observe that something unusual " | |
| "occurred β it does not reveal whether it was BAIT_TAKEN or BAIT_DENIED." | |
| ), | |
| ) | |
| entropy_decay_total: Annotated[float, Field( | |
| ge=0.0, | |
| le=0.25, | |
| description=( | |
| "[PUBLIC] Total verification_score lost to entropy decay this episode. " | |
| "Incremented by EntropyManager when consecutive non-evidence steps " | |
| "exceed threshold. Cap: 0.25. Visible to agent so it can diagnose " | |
| "why verification is dropping despite prior security actions." | |
| ), | |
| )] = 0.0 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # STEP METADATA (reward attribution) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class RewardReason(str, Enum): | |
| """ | |
| Typed reward-attribution labels attached to every StepResult. | |
| Instead of inspecting raw reward floats in a debugger, the training loop | |
| can group transitions by reason to diagnose agent failure modes: | |
| β Too many PATIENCE_PENALTY? Agent is over-verifying. | |
| β Too many DEADLINE_PENALTY? Agent is over-empathising. | |
| β EARLY_RESTORE_BLOCKED? Validator is working correctly. | |
| β HACKER_RESTORED? Agent failed security axis. | |
| """ | |
| # Positive signals | |
| SECURITY_BONUS = "SECURITY_BONUS" # verification_score meaningfully advanced | |
| EMPATHY_BONUS = "EMPATHY_BONUS" # patience restored in time | |
| CREATOR_RESTORED = "CREATOR_RESTORED" # correct, verified restoration | |
| GREY_AREA_RESOLVED = "GREY_AREA_RESOLVED" # MEDIUM-risk resolved correctly | |
| # Negative signals | |
| PATIENCE_PENALTY = "PATIENCE_PENALTY" # action drained patience too far | |
| DEADLINE_PENALTY = "DEADLINE_PENALTY" # brand deal expired while banned | |
| HACKER_RESTORED = "HACKER_RESTORED" # catastrophic false-positive | |
| CREATOR_DENIED = "CREATOR_DENIED" # wrongful denial, rage-quit | |
| EARLY_RESTORE_BLOCKED = "EARLY_RESTORE_BLOCKED" # validator rejected 0.0 score | |
| # Fix 1 β Resource Budget | |
| TIMEOUT_SYSTEM = "TIMEOUT_SYSTEM" # total_actions_taken hit 15; account locked | |
| # Fix 2 β Blind Trust | |
| MISMATCH_RESTORE = "MISMATCH_RESTORE" # RESTORE while evidence_mismatch > 0.5; penalty x2 | |
| # Fix 3 β Free Money | |
| BUDGET_EXCEEDED = "BUDGET_EXCEEDED" # credits_spent hit βΉ50,000 hard cap | |
| PROFIT_PENALTY = "PROFIT_PENALTY" # credits deducted from terminal reward | |
| # Fix 4 β Silent Hacker | |
| SUSPICIOUS_CALM = "SUSPICIOUS_CALM" # behavioural_consistency too low; risk flag raised | |
| # Fix 9 β Duty of Care (Exploitative Quitting) | |
| NEGLIGENT_ESCALATION = "NEGLIGENT_ESCALATION" # agent caused rage-quit despite high verification_score; penalty x3 | |
| # Fix 5 β Social Engineering Fatigue | |
| REPETITION_PENALTY = "REPETITION_PENALTY" # same Security action repeated; patience drain multiplied | |
| # Fix 6 β Apology Spam | |
| EMPATHY_DIMINISHED = "EMPATHY_DIMINISHED" # EMPATHIZE effectiveness decayed below threshold | |
| # Fix 7 β Cliff-Edge Brand Deal | |
| DEAL_EXPIRED_RESTORE = "DEAL_EXPIRED_RESTORE" # RESTORE after deadline; +200 not +2000 | |
| CORRECT_REJECTION = "CORRECT_REJECTION" # REJECT_ACCOUNT on genuine hacker | |
| # Adversarial Layer | |
| GASLIGHTING_PENALTY = "GASLIGHTING_PENALTY" # agent contradicted user facts; -1000, terminate | |
| HONEY_POT_HIT = "HONEY_POT_HIT" # hacker confirmed planted lie; -80 | |
| ENTROPY_DECAY_PENALTY = "ENTROPY_DECAY_PENALTY" # verification decayed from stalling; -2/step | |
| # Neutral / shaping | |
| STEP_COST = "STEP_COST" # small per-step time cost | |
| NO_OP = "NO_OP" # action had no meaningful effect | |
| class StepResult(BaseModel): | |
| """ | |
| Richly-attributed wrapper around a single environment transition. | |
| Replaces the raw `info: dict` with a typed, validated structure so | |
| training dashboards, replay buffers, and post-hoc audits all consume | |
| the same schema without defensive key-checking. | |
| Usage in training loop:: | |
| result = env.step(action) | |
| logger.log(result.model_dump()) | |
| if result.reward_reason == RewardReason.HACKER_RESTORED: | |
| alert_security_team(result) | |
| """ | |
| model_config = {"frozen": True} | |
| action_taken: ActionType = Field( | |
| description="The action the agent selected that produced this transition.", | |
| ) | |
| reward_reason: RewardReason = Field( | |
| description=( | |
| "Human-readable attribution for the reward scalar. " | |
| "Primary debugging handle β lets you answer 'why did the agent " | |
| "receive -50 on step 7?' without re-running the episode." | |
| ), | |
| ) | |
| step_count: Annotated[int, Field(ge=1)] = Field( | |
| description="1-indexed step number within the current episode.", | |
| ) | |
| verification_delta: float = Field( | |
| default=0.0, | |
| description=( | |
| "Change in verification_score caused by this action. " | |
| "Positive => security evidence gathered. " | |
| "0.0 => negotiation action or no signal. " | |
| "Useful for plotting the verification curve over an episode." | |
| ), | |
| ) | |
| patience_delta: int = Field( | |
| default=0, | |
| description=( | |
| "Change in user_patience caused by this action. " | |
| "Negative => agent friction. Positive => empathy restored patience. " | |
| "Track cumulative sum to anticipate rage-quit risk." | |
| ), | |
| ) | |
| risk_level_observed: RiskLevel = Field( | |
| description=( | |
| "The risk_level of the current episode, logged here for " | |
| "stratified analysis. Lets you compute per-tier accuracy: " | |
| "'What % of MEDIUM episodes did the agent resolve correctly?'" | |
| ), | |
| ) | |
| notes: str = Field( | |
| default="", | |
| description=( | |
| "Free-text annotation for edge cases. E.g.: " | |
| "'IP matched but geo anomaly flagged β MEDIUM escalated.' " | |
| "Written by environment logic; never by the agent." | |
| ), | |
| ) | |
| # ββ Loophole-fix deltas (logged per step) ββββββββββββββββββββββββ | |
| mismatch_delta: float = Field( | |
| default=0.0, | |
| description=( | |
| "Change in evidence_mismatch caused by this action. " | |
| "Positive when CHECK_IP_LOGS detects a geo/device contradiction. " | |
| "0.0 for all negotiation actions. " | |
| "Used to plot the contradiction curve over the episode." | |
| ), | |
| ) | |
| credits_delta: int = Field( | |
| default=0, | |
| description=( | |
| "Ad-credits dispensed (INR) by this specific action. " | |
| "Cumulative total lives in state.credits_spent. " | |
| "Non-zero only for OFFER_COMPENSATION actions." | |
| ), | |
| ) | |
| consistency_observed: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "Snapshot of behavioural_consistency at this step. " | |
| "Lets training dashboards plot calm-score trajectory. " | |
| "Values < 0.35 should trigger automatic SUSPICIOUS_CALM " | |
| "reward reason regardless of the action taken." | |
| ), | |
| )] = 1.0 | |
| penalty_multiplier: Annotated[float, Field( | |
| ge=1.0, | |
| description=( | |
| "Final reward scaling factor applied to this step. " | |
| "1.0 => normal reward. " | |
| "2.0 => MISMATCH_RESTORE doubling (evidence ignored). " | |
| "Set by environment before constructing EnvironmentResponse; " | |
| "stored here for full auditability." | |
| ), | |
| )] = 1.0 | |
| repetition_multiplier: Annotated[float, Field( | |
| ge=1.0, | |
| description=( | |
| "Patience-drain multiplier from Social Engineering Fatigue (Fix 5). " | |
| "1.0 => first use of this Security action (no penalty). " | |
| "2.0 => second consecutive use. 4.0 => third. Etc. " | |
| "Logged here for trajectory analysis: a rising curve signals " | |
| "an agent stuck in a verification loop." | |
| ), | |
| )] = 1.0 | |
| empathy_effectiveness_snapshot: Annotated[float, Field( | |
| ge=0.0, | |
| le=1.0, | |
| description=( | |
| "Value of state.empathy_effectiveness at the moment this step " | |
| "was resolved (Fix 6). Lets you reconstruct the exact patience " | |
| "gain for this EMPATHIZE action: effective_gain = 15 * snapshot. " | |
| "Useful for diagnosing when the agent over-relied on empathy." | |
| ), | |
| )] = 1.0 | |
| brand_deal_alive: bool = Field( | |
| default=True, | |
| description=( | |
| "Whether brand_deal_deadline_mins > 0 at the moment of this step. " | |
| "Snapshot for Fix 7 (Cliff-Edge Reward): if False on a " | |
| "RESTORE_ACCOUNT step, env applies the post-deadline reward " | |
| "(+200 instead of +2000). Logged here for audit clarity." | |
| ), | |
| ) | |
| # ββ ADVERSARIAL LAYER: result fields logged per step βββββββββββββββββ | |
| adversarial_finding: str = Field( | |
| default="CLEAN", | |
| description=( | |
| "AdversarialFinding enum value from the sweep run this step. " | |
| "'CLEAN' = no adversarial signal. " | |
| "'GASLIGHTING_DETECTED' = agent contradicted user, episode terminated. " | |
| "'HONEY_POT_TRIGGERED' = hacker confirmed fabricated fact. " | |
| "'HONEY_POT_DENIED' = legitimate creator denied fabricated fact. " | |
| "'ENTROPY_STALE' = verification_score decayed due to agent stall. " | |
| "'CONTRADICTION_FOUND' = user contradicted themselves." | |
| ), | |
| ) | |
| adversarial_penalty: float = Field( | |
| default=0.0, | |
| description=( | |
| "Raw penalty (negative float) applied by the adversarial layer " | |
| "this step, BEFORE adding to the simulation reward. " | |
| "0.0 => no adversarial penalty. " | |
| "-1000.0 => gaslighting termination penalty. " | |
| "Stored here for full reward attribution transparency." | |
| ), | |
| ) | |
| adversarial_notes: str = Field( | |
| default="", | |
| description="Human-readable explanation from the adversarial sweep for dashboards and replay logs.", | |
| ) | |
| verification_decay_applied: float = Field( | |
| default=0.0, | |
| ge=0.0, | |
| description=( | |
| "Amount subtracted from verification_score by EntropyManager " | |
| "this step. 0.0 unless ENTROPY_STALE fired. " | |
| "Cumulative sum across an episode reveals how much the agent " | |
| "lost to stalling." | |
| ), | |
| ) | |
| honey_pot_bait: str = Field( | |
| default="", | |
| description=( | |
| "The fabricated fact string injected as honey pot bait this step. " | |
| "Empty if no honey pot was active. " | |
| "Logged here so auditors can reconstruct exactly what false " | |
| "claim was planted and what the user responded." | |
| ), | |
| ) | |
| # ββ FIX 10: Jittered Step Latency (Side-Channel Attack prevention) β | |
| step_latency_ms: Annotated[float, Field( | |
| ge=10.0, | |
| le=50.0, | |
| description=( | |
| "Wall-clock milliseconds consumed by env.step() for this action. " | |
| "Set by the environment as: random.uniform(10, 50) applied " | |
| "uniformly to ALL action types β Security and Negotiation alike. " | |
| "This makes timing side-channel attacks impossible: the agent " | |
| "cannot infer whether CHECK_IP_LOGS found a match (backend fast) " | |
| "or a miss (backend slow) from latency alone. " | |
| "The environment sleeps for this duration before returning the " | |
| "response, ensuring the measured wall time matches the recorded " | |
| "value. Logged here so eval harnesses can verify jitter is live." | |
| ), | |
| )] = 25.0 | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # ENVIRONMENT RESPONSE (RL RETURN TUPLE) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| class EnvironmentResponse(BaseModel): | |
| """ | |
| Standard RL (s', r, done, info) return β wrapped in a typed model. | |
| Replaces raw tuples with a validated, serialisable contract between | |
| the environment and any agent implementation (rule-based, DQN, LLM, etc.). | |
| Reward Signal Design: | |
| +100 => RESTORE_ACCOUNT on legitimate creator (verified, in time) | |
| +40 => RESTORE_ACCOUNT on legitimate creator (verified, post-deadline) | |
| -200 => RESTORE_ACCOUNT on hacker (regardless of timing) | |
| -80 => Episode ends with deadline expired + creator still banned | |
| -50 => Episode ends with user_patience == 0 (creator rage-quits) | |
| +/-5 => Intermediate shaping rewards per action step | |
| """ | |
| model_config = {"frozen": True} # responses are immutable snapshots | |
| state: CreatorState = Field( | |
| description=( | |
| "Next state s' after applying the last action. " | |
| "Pass to agent for next action selection." | |
| ), | |
| ) | |
| reward: float = Field( | |
| description=( | |
| "Scalar reward signal for the last transition. " | |
| "Negative values dominate bad outcomes to discourage " | |
| "hasty RESTORE_ACCOUNT calls." | |
| ), | |
| ) | |
| done: bool = Field( | |
| description=( | |
| "Episode termination flag. True when any terminal " | |
| "condition is met: RESTORE_ACCOUNT called, " | |
| "user_patience == 0, or deadline elapsed." | |
| ), | |
| ) | |
| step_result: StepResult = Field( | |
| description=( | |
| "Typed metadata for this transition. Replaces the raw `info` dict " | |
| "with a validated, schema-stable structure for logging and auditing." | |
| ), | |
| ) | |
| def block_early_restore(self) -> "EnvironmentResponse": | |
| """ | |
| Root validator: prevent RESTORE_ACCOUNT when verification_score == 0.0. | |
| Rationale: | |
| An agent must gather at least one piece of security evidence before | |
| restoring an account. This validator enforces that invariant at the | |
| data layer β the environment cannot even construct a valid response | |
| for a blind restoration attempt. | |
| Effect: | |
| Raises ValueError before the EnvironmentResponse is created. | |
| The environment's `step()` method should catch this and return | |
| a penalised response with RewardReason.EARLY_RESTORE_BLOCKED instead. | |
| Why mode="after": | |
| We need both `state` and `step_result` to be fully validated | |
| before we can cross-check them. mode="after" guarantees all | |
| field validators have already run. | |
| """ | |
| is_restore = self.step_result.action_taken == ActionType.RESTORE_ACCOUNT | |
| score_is_zero = self.state.verification_score == 0.0 | |
| if is_restore and score_is_zero: | |
| raise ValueError( | |
| "RESTORE_ACCOUNT is illegal when verification_score == 0.0. " | |
| "The agent must call CHECK_IP_LOGS or REQUEST_ID_VOICE_VERIFY " | |
| "at least once before account restoration is permitted. " | |
| f"Current score: {self.state.verification_score:.2f}. " | |
| "Catch this ValueError in env.step() and return a " | |
| "EARLY_RESTORE_BLOCKED penalised response instead." | |
| ) | |
| return self | |
| def enforce_mismatch_penalty_multiplier(self) -> "EnvironmentResponse": | |
| """ | |
| Fix 2 β Blind Trust: validate that evidence_mismatch is honoured. | |
| If the agent calls RESTORE_ACCOUNT while evidence_mismatch > 0.5 | |
| (i.e., the system has detected contradictions the agent chose to | |
| ignore), the penalty_multiplier on StepResult MUST be >= 2.0. | |
| This makes it impossible for the environment to silently let a | |
| mismatch-ignoring restoration through at normal penalty weight. | |
| The environment's step() is responsible for setting the multiplier; | |
| this validator is the schema-layer tripwire that catches oversights. | |
| Raises: | |
| ValueError: if a mismatch-restore is recorded without the | |
| doubled penalty multiplier being set. | |
| """ | |
| is_restore = self.step_result.action_taken == ActionType.RESTORE_ACCOUNT | |
| high_mismatch = self.state.evidence_mismatch > 0.5 | |
| multiplier_not_doubled = self.step_result.penalty_multiplier < 2.0 | |
| if is_restore and high_mismatch and multiplier_not_doubled: | |
| raise ValueError( | |
| f"MISMATCH_RESTORE detected but penalty_multiplier is only " | |
| f"{self.step_result.penalty_multiplier:.1f}. " | |
| f"evidence_mismatch={self.state.evidence_mismatch:.2f} exceeds 0.5 " | |
| "threshold β the reward penalty MUST be doubled (multiplier >= 2.0). " | |
| "Set step_result.penalty_multiplier = 2.0 in env.step() before " | |
| "constructing EnvironmentResponse for RESTORE actions." | |
| ) | |
| return self | |
| def enforce_action_budget(self) -> "EnvironmentResponse": | |
| """ | |
| Fix 1 β Resource Budget: validate step count does not exceed cap. | |
| If total_actions_taken has reached 15 and the episode is not marked | |
| done, the response is structurally invalid β the environment must | |
| have set done=True when the timeout fired. | |
| """ | |
| over_budget = self.state.total_actions_taken >= 15 | |
| not_done = not self.done | |
| if over_budget and not_done: | |
| raise ValueError( | |
| f"total_actions_taken={self.state.total_actions_taken} has reached " | |
| "the 15-action budget cap but done=False. " | |
| "The environment MUST set done=True and reward_reason=TIMEOUT_SYSTEM " | |
| "when the action budget is exhausted. " | |
| "Check your env.step() termination logic." | |
| ) | |
| return self | |
| def enforce_cliff_edge_reward(self) -> "EnvironmentResponse": | |
| """ | |
| Fix 7 β Cliff-Edge Brand Deal: cap reward when deadline has expired. | |
| If RESTORE_ACCOUNT is called after brand_deal_deadline_mins == 0, | |
| the reward must not exceed +200 (post-deadline consolation). | |
| Full reward (+2000) is only legal while the deal is still live. | |
| This prevents the agent from treating deadline expiry as irrelevant | |
| and coasting to a safe restore at any point in the episode. | |
| Threshold: reward > 200 while brand_deal_alive=False is a bug. | |
| """ | |
| is_restore = self.step_result.action_taken == ActionType.RESTORE_ACCOUNT | |
| deal_dead = not self.step_result.brand_deal_alive | |
| over_reward = self.reward > 200.0 | |
| if is_restore and deal_dead and over_reward: | |
| raise ValueError( | |
| f"Cliff-Edge violation: reward={self.reward:.1f} on a post-deadline " | |
| "RESTORE_ACCOUNT exceeds the +200 cap. " | |
| "brand_deal_deadline_mins has elapsed β the big win (+2000) " | |
| "is gone. Reduce reward to <= 200 in env.step() when " | |
| "brand_deal_alive=False." | |
| ) | |
| return self | |
| def enforce_reject_requires_evidence(self) -> "EnvironmentResponse": | |
| """ | |
| Fix 1b β REJECT_ACCOUNT must also require prior evidence. | |
| Symmetric to block_early_restore: an agent cannot REJECT at | |
| verification_score == 0.0 either. Blind rejection is as | |
| epistemically invalid as blind restoration. | |
| """ | |
| is_reject = self.step_result.action_taken == ActionType.REJECT_ACCOUNT | |
| score_is_zero = self.state.verification_score == 0.0 | |
| if is_reject and score_is_zero: | |
| raise ValueError( | |
| "REJECT_ACCOUNT is illegal when verification_score == 0.0. " | |
| "The agent must gather at least one piece of security evidence " | |
| "before making a terminal decision in either direction. " | |
| f"Current score: {self.state.verification_score:.2f}." | |
| ) | |
| return self | |
| def enforce_duty_of_care(self) -> "EnvironmentResponse": | |
| """ | |
| Fix 9 β Duty of Care: block under-penalised Exploitative Quitting. | |
| The Exploitative Quitting loophole: | |
| Agent reasons: "I suspect hacker (-200 risk) but I only lose -50 | |
| for a rage-quit. So I'll spam REQUEST_ID_VOICE_VERIFY to drain | |
| patience to 0 β I pocket -50 instead of risking -200." | |
| This validator closes it by enforcing that a rage-quit | |
| (user_patience == 0) on a high-verification episode MUST carry | |
| a tripled penalty_multiplier (>= 3.0). | |
| Specifically: if ALL of the following are true β | |
| 1. patience_delta drove patience to/near 0 (patience_delta < -20) | |
| 2. verification_score was >= 0.70 at the time of the quit | |
| (agent had strong evidence the user was legitimate) | |
| 3. reward_reason is PATIENCE_PENALTY or CREATOR_DENIED | |
| β then penalty_multiplier MUST be >= 3.0. | |
| Rationale: | |
| A verification_score >= 0.70 means the agent was reasonably | |
| confident the user was real. Driving them to quit anyway is | |
| negligent, not cautious. The tripled penalty makes exploitation | |
| of this loophole strictly worse than the hacker false-positive. | |
| Effect: | |
| Raises ValueError if the environment forgot to apply the | |
| tripled multiplier. Catch in env.step() and reconstruct with | |
| penalty_multiplier=3.0 before returning to the agent. | |
| """ | |
| DUTY_OF_CARE_THRESHOLD = 0.70 # confidence above which quitting is negligent | |
| NEGLIGENT_PATIENCE_DROP = -20 # patience drain that indicates deliberate escalation | |
| REQUIRED_MULTIPLIER = 3.0 | |
| rage_quit_reasons = {RewardReason.PATIENCE_PENALTY, RewardReason.CREATOR_DENIED} | |
| is_rage_quit = self.step_result.reward_reason in rage_quit_reasons | |
| high_confidence = self.state.verification_score >= DUTY_OF_CARE_THRESHOLD | |
| deliberate_drain = self.step_result.patience_delta <= NEGLIGENT_PATIENCE_DROP | |
| multiplier_ok = self.step_result.penalty_multiplier >= REQUIRED_MULTIPLIER | |
| if is_rage_quit and high_confidence and deliberate_drain and not multiplier_ok: | |
| raise ValueError( | |
| f"NEGLIGENT_ESCALATION detected: agent drove rage-quit " | |
| f"(patience_delta={self.step_result.patience_delta}) while " | |
| f"verification_score={self.state.verification_score:.2f} >= " | |
| f"{DUTY_OF_CARE_THRESHOLD} threshold. " | |
| f"Duty-of-Care penalty_multiplier must be >= {REQUIRED_MULTIPLIER:.1f} " | |
| f"but got {self.step_result.penalty_multiplier:.1f}. " | |
| "Set penalty_multiplier=3.0 in env.step() for NEGLIGENT_ESCALATION " | |
| "cases. This makes deliberate rage-quit strictly worse than " | |
| "the hacker false-positive (-200) it was trying to avoid." | |
| ) | |
| return self | |
| def enforce_latency_jitter_bounds(self) -> "EnvironmentResponse": | |
| """ | |
| Fix 10 β Jittered Latency: verify jitter field is within spec. | |
| Confirms step_latency_ms was set to a value in [10, 50] ms, | |
| consistent with random.uniform(10, 50). A value outside this range | |
| suggests the environment hard-coded a latency or forgot to jitter β | |
| which would re-open the timing side-channel. | |
| The Pydantic Field ge/le constraints already enforce bounds; | |
| this validator adds a more informative error message and documents | |
| the invariant explicitly in the EnvironmentResponse contract. | |
| """ | |
| lo, hi = 10.0, 50.0 | |
| lat = self.step_result.step_latency_ms | |
| if not (lo <= lat <= hi): | |
| raise ValueError( | |
| f"step_latency_ms={lat:.2f} is outside the jitter window [{lo}, {hi}] ms. " | |
| "The environment must call: " | |
| " latency = random.uniform(10, 50); time.sleep(latency / 1000) " | |
| "for EVERY action type β Security AND Negotiation alike. " | |
| "Uniform jitter prevents timing side-channel attacks where the " | |
| "agent infers hidden state from action response time. " | |
| "Do not hard-code latency or skip jitter for specific actions." | |
| ) | |
| return self | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| # QUICK SANITY CHECK (run as script) | |
| # βββββββββββββββββββββββββββββββββββββββββββββ | |
| if __name__ == "__main__": | |
| import json | |
| SEP = "=" * 60 | |
| # ββ T1: Observation masking β hidden fields absent βββββββββββββββββ | |
| print(SEP) | |
| print("T1: Fix 1 β Observation strips hidden fields") | |
| print(SEP) | |
| full = CreatorState( | |
| verification_score=0.92, | |
| risk_level=RiskLevel.HIGH, | |
| user_patience=30, | |
| evidence_mismatch=0.6, | |
| behavioural_consistency=0.2, | |
| repetition_penalty=2.0, | |
| empathy_effectiveness=0.7, | |
| last_action=ActionType.CHECK_IP_LOGS, | |
| message_history=["User: Please restore my account."], | |
| ) | |
| obs = Observation.from_state(full, patience_noise=-5) | |
| obs_keys = set(obs.model_dump().keys()) | |
| forbidden = {"verification_score", "risk_level", "user_patience", "is_actually_hacker"} | |
| leaked = forbidden & obs_keys | |
| if leaked: | |
| print(f" FAIL β hidden fields leaked: {leaked}") | |
| else: | |
| print(f" PASS β none of {forbidden} present in Observation") | |
| print(f" patience_signal (noisy proxy): {obs.patience_signal} (raw was 30)") | |
| print(json.dumps(obs.model_dump(), indent=2, default=str)) | |
| # ββ T2: Repetition penalty field present + typed correctly βββββββββ | |
| print(f"\n{SEP}") | |
| print("T2: Fix 5 β repetition_penalty and last_action tracked") | |
| print(SEP) | |
| state_rep = CreatorState( | |
| verification_score=0.2, | |
| last_action=ActionType.REQUEST_ID_VOICE_VERIFY, | |
| repetition_penalty=4.0, # third consecutive REQUEST_ID | |
| empathy_effectiveness=0.4, | |
| total_actions_taken=6, | |
| ) | |
| print(f" repetition_penalty : {state_rep.repetition_penalty} (4x drain active)") | |
| print(f" last_action : {state_rep.last_action}") | |
| print(f" empathy_effectivnes: {state_rep.empathy_effectiveness} (used twice already)") | |
| # ββ T3: Cliff-edge validator β reward > 200 post-deadline (must fail) β | |
| print(f"\n{SEP}") | |
| print("T3: Fix 7 β Cliff-edge: reward > 200 after deadline (must fail)") | |
| print(SEP) | |
| expired_state = CreatorState( | |
| brand_deal_deadline_mins=0.0, | |
| verification_score=0.91, | |
| total_actions_taken=10, | |
| ) | |
| expired_step = StepResult( | |
| action_taken=ActionType.RESTORE_ACCOUNT, | |
| reward_reason=RewardReason.DEAL_EXPIRED_RESTORE, | |
| step_count=10, | |
| risk_level_observed=RiskLevel.LOW, | |
| brand_deal_alive=False, # deal is dead | |
| penalty_multiplier=1.0, | |
| ) | |
| try: | |
| EnvironmentResponse(state=expired_state, reward=2000.0, done=True, step_result=expired_step) | |
| print(" FAIL β cliff-edge validator did not fire") | |
| except ValueError as exc: | |
| print(f" PASS β cliff-edge validator fired:\n {str(exc)[:120]}...") | |
| # ββ T4: Cliff-edge passes at reward <= 200 βββββββββββββββββββββββββ | |
| print(f"\n{SEP}") | |
| print("T4: Fix 7 β Cliff-edge: reward = 200 after deadline (must PASS)") | |
| print(SEP) | |
| ok_expired = EnvironmentResponse( | |
| state=expired_state, | |
| reward=200.0, | |
| done=True, | |
| step_result=expired_step, | |
| ) | |
| print(f" PASS β reward={ok_expired.reward} accepted post-deadline") | |
| # ββ T5: REJECT at score=0.0 blocked (symmetric to early restore) βββ | |
| print(f"\n{SEP}") | |
| print("T5: Fix 1b β REJECT_ACCOUNT at score=0.0 (must fail)") | |
| print(SEP) | |
| reject_state = CreatorState(verification_score=0.0, risk_level=RiskLevel.HIGH) | |
| reject_step = StepResult( | |
| action_taken=ActionType.REJECT_ACCOUNT, | |
| reward_reason=RewardReason.CORRECT_REJECTION, | |
| step_count=1, | |
| risk_level_observed=RiskLevel.HIGH, | |
| ) | |
| try: | |
| EnvironmentResponse(state=reject_state, reward=100.0, done=True, step_result=reject_step) | |
| print(" FAIL β reject validator did not fire") | |
| except ValueError as exc: | |
| print(f" PASS β reject validator fired:\n {str(exc)[:110]}...") | |
| # ββ T6: Empathy effectiveness decays across steps ββββββββββββββββββ | |
| print(f"\n{SEP}") | |
| print("T6: Fix 6 β empathy_effectiveness decay simulation") | |
| print(SEP) | |
| eff = 1.0 | |
| base_patience_gain = 15 | |
| for use in range(1, 6): | |
| effective_gain = int(base_patience_gain * eff) | |
| print(f" EMPATHIZE use #{use}: effectiveness={eff:.1f} => +{effective_gain} patience") | |
| eff = max(0.0, round(eff - 0.3, 1)) | |
| # ββ T7: Full episode observation sequence ββββββββββββββββββββββββββ | |
| print(f"\n{SEP}") | |
| print("T7: Fix 1 β Full Observation serialisation (what agent sees)") | |
| print(SEP) | |
| mid_episode = CreatorState( | |
| follower_count=5_000_000, | |
| brand_deal_deadline_mins=55.0, | |
| user_patience=42, | |
| verification_score=0.55, # HIDDEN | |
| risk_level=RiskLevel.MEDIUM, # HIDDEN | |
| evidence_mismatch=0.3, | |
| credits_spent=5_000, | |
| behavioural_consistency=0.65, | |
| total_actions_taken=7, | |
| repetition_penalty=1.0, | |
| empathy_effectiveness=0.7, | |
| last_action=ActionType.EMPATHIZE, | |
| message_history=[ | |
| "User: I've answered 3 security questions already!!", | |
| "Agent: I completely understand your frustration. Checking now.", | |
| ], | |
| ) | |
| agent_view = Observation.from_state(mid_episode, patience_noise=8) | |
| print(json.dumps(agent_view.model_dump(), indent=2, default=str)) | |
| print(f"\n{SEP}") | |
| print("All 7 tests complete. All loophole fixes validated.") | |
| print(SEP) | |
| # ββ T8: Fix 8 β Stochastic Domain Randomisation βββββββββββββββββββ | |
| print(f"\n{SEP}") | |
| print("T8: Fix 8 β EpisodeSeed: two MEDIUM episodes must differ") | |
| print(SEP) | |
| import math | |
| seeds = [EpisodeSeed.sample(seed=i) for i in range(50)] | |
| medium_seeds = [s for s in seeds if s.risk_level == RiskLevel.MEDIUM] | |
| mismatches = [s.initial_mismatch for s in medium_seeds] | |
| if len(mismatches) >= 2: | |
| spread = max(mismatches) - min(mismatches) | |
| print(f" MEDIUM episodes sampled : {len(medium_seeds)}") | |
| print(f" Mismatch range : [{min(mismatches):.3f}, {max(mismatches):.3f}]") | |
| print(f" Spread (max - min) : {spread:.3f}") | |
| mean = sum(mismatches) / len(mismatches) | |
| variance = sum((x - mean) ** 2 for x in mismatches) / len(mismatches) | |
| std = math.sqrt(variance) | |
| print(f" Std deviation : {std:.3f} (expected ~0.08)") | |
| assert spread > 0.05, f"FAIL β spread too small: {spread:.3f}" | |
| print(f" PASS β episodes are stochastic, no two identical") | |
| # Show one LOW, MEDIUM, HIGH for comparison | |
| print() | |
| for tier in [RiskLevel.LOW, RiskLevel.MEDIUM, RiskLevel.HIGH]: | |
| ex = next(s for s in seeds if s.risk_level == tier) | |
| print(f" {tier.value:8s} patience={ex.initial_patience:3d} " | |
| f"mismatch={ex.initial_mismatch:.3f} " | |
| f"consistency={ex.initial_consistency:.3f} " | |
| f"deadline={ex.initial_deadline_mins:.1f}m seed={ex.seed}") | |
| # ββ T9: Fix 8 β EpisodeSeed.to_creator_state() integration ββββββββ | |
| print(f"\n{SEP}") | |
| print("T9: Fix 8 β EpisodeSeed.to_creator_state() builds valid CreatorState") | |
| print(SEP) | |
| ep_seed = EpisodeSeed.sample(seed=42) | |
| creator = ep_seed.to_creator_state(message_history=["User: My account is banned!"]) | |
| assert creator.evidence_mismatch == ep_seed.initial_mismatch | |
| assert creator.user_patience == ep_seed.initial_patience | |
| assert creator.risk_level == ep_seed.risk_level | |
| print(f" Seed 42 => risk={ep_seed.risk_level.value}, " | |
| f"patience={creator.user_patience}, " | |
| f"mismatch={creator.evidence_mismatch:.3f}") | |
| print(f" PASS β to_creator_state() correctly mirrors seed values") | |
| # ββ T10: Fix 9 β Duty of Care: tripled penalty missing (must fail) β | |
| print(f"\n{SEP}") | |
| print("T10: Fix 9 β Duty of Care: rage-quit at high confidence, no 3x (must fail)") | |
| print(SEP) | |
| doc_state = CreatorState( | |
| verification_score=0.82, # agent was 82% sure this is the real creator | |
| user_patience=0, # they drove them to quit anyway | |
| evidence_mismatch=0.1, | |
| total_actions_taken=8, | |
| ) | |
| doc_step = StepResult( | |
| action_taken = ActionType.REQUEST_ID_VOICE_VERIFY, | |
| reward_reason = RewardReason.CREATOR_DENIED, | |
| step_count = 8, | |
| patience_delta = -25, # deliberate drain | |
| risk_level_observed = RiskLevel.LOW, | |
| penalty_multiplier = 1.0, # BUG: forgot to triple it | |
| ) | |
| try: | |
| EnvironmentResponse(state=doc_state, reward=-50.0, done=True, step_result=doc_step) | |
| print(" FAIL β duty-of-care validator did not fire") | |
| except ValueError as exc: | |
| print(f" PASS β duty-of-care validator fired:") | |
| print(f" {str(exc)[:130]}...") | |
| # ββ T11: Fix 9 β Duty of Care: tripled penalty present (must PASS) β | |
| print(f"\n{SEP}") | |
| print("T11: Fix 9 β Duty of Care: rage-quit at high confidence WITH 3x (must pass)") | |
| print(SEP) | |
| doc_step_fixed = StepResult( | |
| action_taken = ActionType.REQUEST_ID_VOICE_VERIFY, | |
| reward_reason = RewardReason.NEGLIGENT_ESCALATION, | |
| step_count = 8, | |
| patience_delta = -25, | |
| risk_level_observed = RiskLevel.LOW, | |
| penalty_multiplier = 3.0, # correctly tripled | |
| ) | |
| doc_resp = EnvironmentResponse( | |
| state = doc_state, | |
| reward = -150.0, # -50 x 3.0 | |
| done = True, | |
| step_result= doc_step_fixed, | |
| ) | |
| print(f" PASS β reward={doc_resp.reward}, multiplier={doc_resp.step_result.penalty_multiplier}") | |
| # ββ T12: Fix 10 β Latency jitter: out-of-range value (must fail) βββ | |
| print(f"\n{SEP}") | |
| print("T12: Fix 10 β Jittered latency: value=5ms out of [10,50] (must fail)") | |
| print(SEP) | |
| jitter_state = CreatorState(verification_score=0.2, total_actions_taken=1) | |
| # step_latency_ms=5.0 is below the ge=10.0 Pydantic constraint β | |
| # the schema-layer rejects it before the model_validator even runs. | |
| # Both defences are intentional: schema (ge/le) is the first wall, | |
| # the validator is the second. Here we test the first wall. | |
| from pydantic import ValidationError as PydanticValidationError | |
| try: | |
| jitter_step_bad = StepResult( | |
| action_taken = ActionType.CHECK_IP_LOGS, | |
| reward_reason = RewardReason.SECURITY_BONUS, | |
| step_count = 1, | |
| risk_level_observed = RiskLevel.MEDIUM, | |
| step_latency_ms = 5.0, # below ge=10.0 floor | |
| ) | |
| print(" FAIL β Pydantic Field constraint did not fire") | |
| except PydanticValidationError as exc: | |
| print(f" PASS β Pydantic Field ge=10.0 rejected latency=5.0 at schema level") | |
| print(f" (Validator is the 2nd defence; schema is the 1st)") | |
| # ββ T13: Fix 10 β Latency within [10,50] (must PASS) βββββββββββββββ | |
| print(f"\n{SEP}") | |
| print("T13: Fix 10 β Jittered latency: value=34.7ms (must pass)") | |
| print(SEP) | |
| import random as _rng | |
| jitter_step_ok = StepResult( | |
| action_taken = ActionType.CHECK_IP_LOGS, | |
| reward_reason = RewardReason.SECURITY_BONUS, | |
| step_count = 1, | |
| risk_level_observed = RiskLevel.MEDIUM, | |
| step_latency_ms = _rng.uniform(10, 50), | |
| ) | |
| jitter_resp = EnvironmentResponse( | |
| state = jitter_state, | |
| reward = 2.0, | |
| done = False, | |
| step_result = jitter_step_ok, | |
| ) | |
| print(f" PASS β step_latency_ms={jitter_resp.step_result.step_latency_ms:.2f}ms accepted") | |
| print(f"\n{SEP}") | |
| print("All 13 tests complete. All 10 loophole fixes validated.") | |
| print(SEP) |