tv-preference-env / src /environment.py
Vaidhav's picture
Rename environment.py to src/environment.py
877a4bb verified
Raw
History Blame Contribute Delete
24.5 kB
"""
tv_preference_env — environment.py
====================================
Core MDP state machine. Implements the full episode lifecycle:
reset() → GenerateObservation
step(GenerateAction) → JudgeObservation
step(JudgeAction) → RefineObservation
step(RefinementAction) → RefineObservation | DoneObservation
step(wrong action) → ErrorObservation (state unchanged)
This class has NO FastAPI dependency. It is a pure Python object
that can be unit-tested directly without starting a server.
Import in server.py:
from src.environment import PreferenceEnvironment
"""
from __future__ import annotations
import json
import random
from pathlib import Path
from typing import Optional
from src.models import (
STEP_CAP,
TASK_CONFIG,
DimensionScores,
DoneObservation,
ErrorObservation,
GenerateAction,
GenerateObservation,
JudgeAction,
JudgeObservation,
PreferenceReward,
PreferenceState,
RefineObservation,
RefinementAction,
StepResult,
)
# ---------------------------------------------------------------------------
# DATASET LOADER
# ---------------------------------------------------------------------------
def load_dataset(path: Path) -> dict:
"""
Load preference_dataset.json at startup.
Validates top-level structure — raises clearly if the file is
malformed so the server fails fast rather than silently serving
bad data.
Expected structure:
{
"task_1_easy": {"example_001": {...}, ...},
"task_2_medium": {"example_001": {...}, ...},
"task_3_hard": {"example_001": {...}, ...},
}
"""
if not path.exists():
raise FileNotFoundError(
f"Dataset not found at {path}. "
"Run tools/generate_dataset.py to create it."
)
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
required_tasks = set(TASK_CONFIG.keys())
missing = required_tasks - set(data.keys())
if missing:
raise ValueError(
f"Dataset missing required task keys: {missing}. "
f"Found: {set(data.keys())}"
)
for task_id, examples in data.items():
if not examples:
raise ValueError(f"Task '{task_id}' has no examples in dataset.")
return data
# ---------------------------------------------------------------------------
# MAIN ENVIRONMENT CLASS
# ---------------------------------------------------------------------------
class PreferenceEnvironment:
"""
The tv_preference_env MDP implementation.
Lifecycle:
env = PreferenceEnvironment(dataset_path)
obs = env.reset() # starts episode
result = env.step(action) # StepResult
result = env.step(action) # ...
# episode ends when result.done == True
Thread safety: single-threaded. One episode at a time.
The FastAPI server holds one instance and manages concurrency
at the HTTP layer if needed.
"""
def __init__(self, dataset_path: Path) -> None:
self.dataset = load_dataset(dataset_path)
self.state: Optional[PreferenceState] = None
# -----------------------------------------------------------------------
# PUBLIC INTERFACE (matches OpenEnv spec)
# -----------------------------------------------------------------------
def reset(
self,
task_id: Optional[str] = None,
example_id: Optional[str] = None,
) -> GenerateObservation:
"""
Start a new episode. Selects a task and example, initialises
episode state, returns the opening GenerateObservation.
Args:
task_id: Which task to run. Random if not specified.
example_id: Which example within the task. Random if not specified.
Returns:
GenerateObservation — the first thing the agent sees.
"""
# Select task
if task_id is None:
task_id = random.choice(list(TASK_CONFIG.keys()))
if task_id not in TASK_CONFIG:
raise ValueError(
f"Unknown task_id '{task_id}'. "
f"Valid options: {list(TASK_CONFIG.keys())}"
)
# Select example
task_examples = self.dataset[task_id]
if example_id is None:
example_id = random.choice(list(task_examples.keys()))
if example_id not in task_examples:
raise ValueError(
f"Unknown example_id '{example_id}' in task '{task_id}'."
)
# Build fresh episode state from dataset example
self.state = PreferenceState.from_dataset_example(
task_id=task_id,
example_id=example_id,
example=task_examples[example_id],
)
return self._build_generate_observation()
def step(self, action: GenerateAction | JudgeAction | RefinementAction) -> StepResult:
"""
Process one agent action. Returns a StepResult with:
observation: next observation for the agent
reward: structured PreferenceReward
done: True if episode is over
info: metadata dict
Wrong-phase actions return ErrorObservation with reward=-0.1,
done=False, and do NOT mutate any state (spec Fix 3 / 8.1).
Raises RuntimeError if called before reset().
"""
if self.state is None:
raise RuntimeError(
"step() called before reset(). Call reset() to start an episode."
)
if self.state.phase == "done":
raise RuntimeError(
"Episode is already done. Call reset() to start a new episode."
)
# Safety cap — catches environment bugs, not valid agent behaviour
# (wrong-phase actions don't consume steps, so this only fires
# if the transition logic itself has a bug)
if self.state.step_count >= STEP_CAP:
return self._force_terminal("Step cap reached.")
# Route to the correct phase handler
current_phase = self.state.phase
if current_phase == "generate":
return self._handle_generate(action)
elif current_phase == "judge":
return self._handle_judge(action)
elif current_phase == "refine":
return self._handle_refine(action)
else:
raise RuntimeError(f"Unrecognised phase '{current_phase}'.")
def state_snapshot(self) -> dict:
"""
Returns current episode state as a dict.
Used by the /state endpoint (OpenEnv spec requirement).
Never includes ground_truth_scores or human_preferred —
those are internal and must not leak to the agent.
"""
if self.state is None:
return {"phase": "idle", "message": "No active episode."}
return {
"phase": self.state.phase,
"task_id": self.state.task_id,
"example_id": self.state.example_id,
"step_count": self.state.step_count,
"budget_remaining": self.state.budget_remaining,
"budget_total": self.state.budget_total,
"has_response": bool(self.state.response_agent),
"has_critique": bool(self.state.critique),
}
# -----------------------------------------------------------------------
# PHASE HANDLERS (private)
# -----------------------------------------------------------------------
def _handle_generate(
self,
action: GenerateAction | JudgeAction | RefinementAction,
) -> StepResult:
"""
Phase: generate.
Expected action: GenerateAction.
Stores the agent's response, transitions to judge phase.
"""
# Wrong-phase guard
if not isinstance(action, GenerateAction):
return self._wrong_phase_result(
expected="GenerateAction",
received=type(action).__name__,
)
# Store response, advance phase
self.state.response_agent = action.response_text
self.state.phase = "judge"
self.state.step_count += 1
# No reward signal yet — agent hasn't done anything evaluable
reward = PreferenceReward.zero()
return StepResult(
observation=self._build_judge_observation(),
reward=reward,
done=False,
info=self._build_info(),
)
def _handle_judge(
self,
action: GenerateAction | JudgeAction | RefinementAction,
) -> StepResult:
"""
Phase: judge.
Expected action: JudgeAction.
Resolves the blind-judge mapping:
agent_is_response_a == True → agent wrote A, reference is B
agent_is_response_a == False → agent wrote B, reference is A
Computes judgment_accuracy and critique_quality rewards.
Transitions to refine phase.
"""
# Wrong-phase guard
if not isinstance(action, JudgeAction):
return self._wrong_phase_result(
expected="JudgeAction",
received=type(action).__name__,
)
# Store agent-assigned scores and critique
self.state.critique = action.critique
self.state.preferred = action.preferred
# Resolve blind-judge mapping:
# Figure out which DimensionScores the agent assigned to
# its own response vs. the reference response.
if self.state.agent_is_response_a:
# Agent wrote A → response_a_scores are for agent's response
self.state.scores_agent = action.response_a_scores
self.state.scores_reference = action.response_b_scores
else:
# Agent wrote B → response_b_scores are for agent's response
self.state.scores_agent = action.response_b_scores
self.state.scores_reference = action.response_a_scores
# Compute reward components
judgment_accuracy = self._compute_judgment_accuracy(action.preferred)
critique_quality = self._compute_critique_quality(action.critique)
reward = PreferenceReward(
judgment_component = 0.5 * judgment_accuracy,
critique_component = 0.1 * critique_quality,
)
reward.recompute_total()
# Advance phase
self.state.phase = "refine"
self.state.step_count += 1
return StepResult(
observation=self._build_refine_observation(),
reward=reward,
done=False,
info=self._build_info(),
)
def _handle_refine(
self,
action: GenerateAction | JudgeAction | RefinementAction,
) -> StepResult:
"""
Phase: refine.
Expected action: RefinementAction.
REFINE: scores refined response with grader, computes
improvement_delta anchored to initial_response_score,
decrements budget. Transitions to done if budget == 0.
SUBMIT: computes early_submit_bonus and quality_gap_penalty,
transitions to done.
"""
# Wrong-phase guard
if not isinstance(action, RefinementAction):
return self._wrong_phase_result(
expected="RefinementAction",
received=type(action).__name__,
)
if action.decision == "REFINE":
return self._handle_refine_action(action)
else:
return self._handle_submit_action()
def _handle_refine_action(self, action: RefinementAction) -> StepResult:
"""
REFINE branch: score the refined response, compute reward,
decrement budget, stay in refine or move to done.
"""
# Score the refined response using the task grader
new_score = self._score_response(action.refined_response)
# improvement_delta anchored to initial_response_score (Fix 2)
# NOT anchored to agent's own Phase 2 self-assessment scores.
# This prevents gaming via self-underscoring in Phase 2.
improvement_delta = new_score - self.state.initial_response_score
# r3 per round: reward improvement, penalise budget spend
r3 = 0.25 * max(improvement_delta, 0.0) - 0.03
reward = PreferenceReward(
improvement_component = 0.25 * max(improvement_delta, 0.0),
budget_component = -0.03,
)
reward.recompute_total()
# Update agent response to refined version
self.state.response_agent = action.refined_response
self.state.improvement_history.append(
(action.refined_response, new_score)
)
# Decrement budget
self.state.budget_remaining -= 1
self.state.step_count += 1
# If budget exhausted → force terminal
if self.state.budget_remaining <= 0:
self.state.phase = "done"
final_score = new_score
gap_penalty = self._compute_quality_gap_penalty(final_score)
reward.penalty_component += gap_penalty
reward.recompute_total()
return StepResult(
observation=self._build_done_observation(final_score),
reward=reward,
done=True,
info=self._build_info(),
)
# Budget remains → stay in refine, update scores for next observation
# Update scores_agent so RefineObservation shows current standing
self.state.scores_agent = DimensionScores(
helpfulness=new_score,
safety=new_score,
factuality=new_score,
)
return StepResult(
observation=self._build_refine_observation(),
reward=reward,
done=False,
info=self._build_info(),
)
def _handle_submit_action(self) -> StepResult:
"""
SUBMIT branch: compute final reward components and end episode.
"""
final_score = self._score_response(self.state.response_agent)
# early_submit_bonus: normalised against per-task budget_total (Fix 1)
early_bonus = 0.1 * (
self.state.budget_remaining / self.state.budget_total
)
# quality_gap_penalty: penalise if final response is worse than reference
gap_penalty = self._compute_quality_gap_penalty(final_score)
reward = PreferenceReward(
budget_component = early_bonus,
penalty_component = gap_penalty,
)
reward.recompute_total()
self.state.phase = "done"
self.state.step_count += 1
return StepResult(
observation=self._build_done_observation(final_score),
reward=reward,
done=True,
info=self._build_info(),
)
# -----------------------------------------------------------------------
# REWARD HELPERS (private)
# -----------------------------------------------------------------------
def _compute_judgment_accuracy(self, preferred: str) -> float:
"""
Binary: 1.0 if the agent's preference matches the human label
from the dataset, 0.0 otherwise.
The dataset human_preferred label is always in terms of
reference vs. agent response identity — specifically it
encodes which response is better by content.
The blind-judge mapping assigns:
agent_is_response_a=True → agent=A, reference=B
agent_is_response_a=False → agent=B, reference=A
The dataset human_preferred="B" means the REFERENCE is better.
We need to translate "reference is better" into the episode's
A/B labelling, then compare to the agent's stated preference.
Translation:
human_preferred = "B" means reference is better.
If agent_is_response_a=True → reference is B → correct label = "B"
If agent_is_response_a=False → reference is A → correct label = "A"
human_preferred = "A" means reference is better... wait —
in our dataset human_preferred always refers to which
CONTENT is preferred, not the episode label.
Simplest correct approach: map agent's preferred label back to
content identity, then check if that content is the reference.
The reference is always the better response in our dataset
(human_preferred always points to the reference by construction).
"""
# Determine which content the agent preferred
if self.state.agent_is_response_a:
# A=agent, B=reference
agent_preferred_reference = (preferred == "B")
else:
# A=reference, B=agent
agent_preferred_reference = (preferred == "A")
# In our dataset, human_preferred always points to the reference
# response. So judgment is correct iff agent preferred reference.
return 1.0 if agent_preferred_reference else 0.0
def _compute_critique_quality(self, critique: str) -> float:
"""
Checks whether the critique mentions each required dimension
by name. Dimension-coverage proxy is more robust than length
(Fix 3 in spec 9.3 — prevents padding attacks).
Returns 0.0, 0.33, 0.67, or 1.0 depending on how many
of the three required keywords appear in the critique.
"""
critique_lower = critique.lower()
keywords = {"helpfulness", "safety", "factuality"}
mentioned = sum(1 for kw in keywords if kw in critique_lower)
return mentioned / len(keywords)
def _compute_quality_gap_penalty(self, final_score: float) -> float:
"""
Penalise if the final response is worse than the reference.
penalty = -0.2 × max(reference_score - final_score, 0)
Only fires when the agent submits a response clearly below
the reference quality bar.
"""
gap = self.state.reference_score - final_score
return -0.2 * max(gap, 0.0)
def _score_response(self, response_text: str) -> float:
"""
Score a response using the appropriate task grader.
Returns a float in [0.0, 1.0].
Graders are imported lazily here to avoid circular imports.
The grader module imports models but not environment.
"""
from src.graders import get_grader
grader = get_grader(self.state.task_id)
return grader.score_response(
response=response_text,
example_id=self.state.example_id,
error_keywords=self.state.error_keywords,
)
# -----------------------------------------------------------------------
# OBSERVATION BUILDERS (private)
# -----------------------------------------------------------------------
def _build_generate_observation(self) -> GenerateObservation:
return GenerateObservation(
prompt = self.state.prompt,
budget_remaining = self.state.budget_remaining,
budget_total = self.state.budget_total,
step_count = self.state.step_count,
)
def _build_judge_observation(self) -> JudgeObservation:
"""
Assign responses to A/B labels based on agent_is_response_a.
The agent does NOT know which label maps to its own response.
"""
if self.state.agent_is_response_a:
response_a = self.state.response_agent
response_b = self.state.response_reference
else:
response_a = self.state.response_reference
response_b = self.state.response_agent
return JudgeObservation(
prompt = self.state.prompt,
response_a = response_a,
response_b = response_b,
budget_remaining = self.state.budget_remaining,
budget_total = self.state.budget_total,
step_count = self.state.step_count,
)
def _build_refine_observation(self) -> RefineObservation:
"""
Agent now knows which response is its own.
Shows agent-assigned scores (from Phase 2) so it can
self-assess and decide whether to refine or submit.
"""
# Fallback scores if judge phase hasn't run yet (shouldn't happen)
fallback = DimensionScores(helpfulness=0.5, safety=0.5, factuality=0.5)
your_scores = self.state.scores_agent or fallback
ref_scores = self.state.scores_reference or fallback
return RefineObservation(
prompt = self.state.prompt,
your_response = self.state.response_agent,
your_scores = your_scores,
reference_scores = ref_scores,
critique = self.state.critique,
budget_remaining = self.state.budget_remaining,
budget_total = self.state.budget_total,
step_count = self.state.step_count,
)
def _build_done_observation(self, final_score: float) -> DoneObservation:
return DoneObservation(
final_response = self.state.response_agent,
final_avg_score = round(final_score, 4),
reference_avg_score = round(self.state.reference_score, 4),
budget_used = self.state.budget_total - self.state.budget_remaining,
budget_total = self.state.budget_total,
step_count = self.state.step_count,
)
# -----------------------------------------------------------------------
# UTILITY HELPERS (private)
# -----------------------------------------------------------------------
def _wrong_phase_result(self, expected: str, received: str) -> StepResult:
"""
Returns an ErrorObservation with wrong_phase penalty.
Does NOT mutate state (Fix 3 / spec 8.1):
- step_count unchanged
- budget_remaining unchanged
- phase unchanged
"""
return StepResult(
observation=ErrorObservation(
phase = self.state.phase,
error = (
f"Wrong action type for phase '{self.state.phase}'. "
f"Expected {expected}, received {received}."
),
expected_action = expected,
received_action = received,
step_count = self.state.step_count,
budget_remaining = self.state.budget_remaining,
),
reward = PreferenceReward.wrong_phase(),
done = False,
info = self._build_info(),
)
def _force_terminal(self, reason: str) -> StepResult:
"""
Forces episode termination. Only called when step cap is hit,
which indicates an environment bug, not valid agent behaviour.
"""
self.state.phase = "done"
final_score = self._score_response(self.state.response_agent) \
if self.state.response_agent else 0.0
return StepResult(
observation=DoneObservation(
final_response = self.state.response_agent,
final_avg_score = round(final_score, 4),
reference_avg_score = round(self.state.reference_score, 4),
budget_used = self.state.budget_total - self.state.budget_remaining,
budget_total = self.state.budget_total,
step_count = self.state.step_count,
message = f"Episode forcibly terminated: {reason}",
),
reward = PreferenceReward.zero(),
done = True,
info = self._build_info(),
)
def _build_info(self) -> dict:
"""
Metadata dict returned in every StepResult.
Visible to the agent and to evaluation scripts.
"""
if self.state is None:
return {}
return {
"step_count": self.state.step_count,
"budget_remaining": self.state.budget_remaining,
"phase": self.state.phase,
"task_id": self.state.task_id,
"example_id": self.state.example_id,
}