Spaces:
Sleeping
Sleeping
File size: 14,630 Bytes
6211967 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 | # Copyright (c) Meta Platforms, Inc. and affiliates.
# All rights reserved.
#
# This source code is licensed under the BSD-style license found in the
# LICENSE file in the root directory of this source tree.
"""
Code Output Assessment Environment Implementation.
An RL environment that tests an agent's ability to solve coding problems
across three difficulty levels with automated grading and reward shaping.
"""
import random
from uuid import uuid4
from typing import Dict, List, Tuple, Literal
from openenv.core.env_server.interfaces import Environment
from openenv.core.env_server.types import State
try:
from ..models import CodeAssessmentAction, CodeAssessmentObservation
except ImportError:
from models import CodeAssessmentAction, CodeAssessmentObservation
# Problem sets for each difficulty level
PROBLEMS = {
"easy": [
{
"description": "Add two numbers. Given input 'a,b', output a+b.",
"test_cases": [("3,5", "8"), ("10,20", "30"), ("0,0", "0"), ("-5,5", "0")],
},
{
"description": "Reverse a string. Given input 'hello', output 'olleh'.",
"test_cases": [("hello", "olleh"), ("world", "dlrow"), ("a", "a"), ("12345", "54321")],
},
{
"description": "Count vowels in a string (a,e,i,o,u). Return the count.",
"test_cases": [("hello", "2"), ("aeiou", "5"), ("xyz", "0"), ("programming", "3")],
},
{
"description": "Find maximum of two numbers. Given input 'a,b', output the larger number.",
"test_cases": [("5,10", "10"), ("20,15", "20"), ("7,7", "7"), ("-5,3", "3")],
},
],
"medium": [
{
"description": "Check if a string is a palindrome. Output 'true' or 'false'.",
"test_cases": [("racecar", "true"), ("hello", "false"), ("a", "true"), ("abba", "true")],
},
{
"description": "Find the sum of all numbers in a comma-separated list. Input: '1,2,3', Output: '6'.",
"test_cases": [("1,2,3", "6"), ("10,20,30", "60"), ("5", "5"), ("-1,1", "0")],
},
{
"description": "Count occurrences of a character in a string. Input format: 'string,char'. Output: count.",
"test_cases": [("hello,l", "2"), ("programming,m", "2"), ("test,x", "0"), ("aaa,a", "3")],
},
{
"description": "Remove duplicates from a comma-separated list, keep order. Input: '1,2,2,3', Output: '1,2,3'.",
"test_cases": [("1,2,2,3", "1,2,3"), ("a,b,a,c", "a,b,c"), ("1,1,1", "1"), ("1,2,3", "1,2,3")],
},
],
"hard": [
{
"description": "Find the longest word in a sentence. Input: sentence. Output: longest word.",
"test_cases": [
("the quick brown fox", "quick"),
("hello world", "hello"),
("a bb ccc", "ccc"),
("programming is fun", "programming"),
],
},
{
"description": "Find the nth Fibonacci number (0-indexed). Input: n. Output: fibonacci(n).",
"test_cases": [("0", "0"), ("1", "1"), ("5", "5"), ("10", "55")],
},
{
"description": "Check if parentheses are balanced. Input: string with (){}[]. Output: 'true' or 'false'.",
"test_cases": [("()", "true"), ("({[]})", "true"), ("(]", "false"), ("(()", "false")],
},
{
"description": "Find prime numbers up to n (comma-separated). Input: n. Output: primes.",
"test_cases": [("10", "2,3,5,7"), ("20", "2,3,5,7,11,13,17,19"), ("2", "2"), ("1", "")],
},
],
}
class CodeAssessmentEnvironment(Environment):
"""
Code Output Assessment Environment.
Tests an agent's ability to solve coding problems across three difficulty levels.
Features automated grading with normalized scores (0.0-1.0) and shaped rewards.
Difficulty Levels:
- Easy: Basic operations (addition, string reversal, simple counting)
- Medium: String/list processing, basic algorithms
- Hard: Advanced algorithms, recursion, complex logic
Grading System:
All graders produce normalized scores between 0.0-1.0:
- 1.0: Perfect answer
- 0.5-0.9: High partial credit (very close)
- 0.2-0.4: Low partial credit (some correct elements)
- 0.0: Completely incorrect
Reward Structure (grader score × difficulty multiplier):
- Easy: score × 1.0 (max +1.0 for correct, +0.5 partial, 0.0 wrong)
- Medium: score × 2.0 (max +2.0 for correct, +1.0 partial, 0.0 wrong)
- Hard: score × 5.0 (max +5.0 for correct, +2.5 partial, -0.3 wrong)
- Streak bonus: +0.5 for 3+ consecutive correct answers
"""
SUPPORTS_CONCURRENT_SESSIONS: bool = True
MAX_STEPS: int = 15 # Maximum steps per episode
def __init__(self):
"""Initialize the code assessment environment."""
self._state = State(episode_id=str(uuid4()), step_count=0)
self._current_problem: Dict = {}
self._current_test_case_idx: int = 0
self._difficulty: Literal["easy", "medium", "hard"] = "easy"
self._problems_solved: int = 0
self._current_streak: int = 0
self._total_reward: float = 0.0
def reset(self) -> CodeAssessmentObservation:
"""
Reset the environment and present the first problem.
Returns:
CodeAssessmentObservation with the first problem description
"""
self._state = State(episode_id=str(uuid4()), step_count=0)
self._problems_solved = 0
self._current_streak = 0
self._total_reward = 0.0
self._difficulty = "easy"
# Select a random problem from the easy category
self._current_problem = random.choice(PROBLEMS["easy"])
self._current_test_case_idx = 0
test_input, _ = self._current_problem["test_cases"][0]
return CodeAssessmentObservation(
problem_description=self._current_problem["description"],
difficulty=self._difficulty,
test_case_input=test_input,
expected_output=None,
feedback="Welcome! Solve the problem and submit your answer.",
is_correct=False,
partial_credit=0.0,
problems_solved=0,
current_streak=0,
done=False,
reward=0.0,
)
def step(self, action: CodeAssessmentAction) -> CodeAssessmentObservation: # type: ignore[override]
"""
Evaluate the submitted answer and provide feedback.
Args:
action: CodeAssessmentAction containing the agent's answer
Returns:
CodeAssessmentObservation with grading results and next problem
"""
self._state.step_count += 1
# Get current test case
test_input, expected_output = self._current_problem["test_cases"][self._current_test_case_idx]
# Grade the answer
is_correct, partial_credit, feedback = self._grade_answer(action.answer, expected_output)
# Calculate reward
reward = self._calculate_reward(is_correct, partial_credit)
self._total_reward += reward
# Update statistics
if is_correct:
self._problems_solved += 1
self._current_streak += 1
else:
self._current_streak = 0
# Check if episode should end
done = self._state.step_count >= self.MAX_STEPS
# Move to next problem if current one is solved
if is_correct:
self._advance_to_next_problem()
# Get next test case
test_input, _ = self._current_problem["test_cases"][self._current_test_case_idx]
return CodeAssessmentObservation(
problem_description=self._current_problem["description"],
difficulty=self._difficulty,
test_case_input=test_input,
expected_output=expected_output if not is_correct else None,
feedback=feedback,
is_correct=is_correct,
partial_credit=partial_credit,
problems_solved=self._problems_solved,
current_streak=self._current_streak,
done=done,
reward=reward,
metadata={
"total_reward": self._total_reward,
"step": self._state.step_count,
"difficulty": self._difficulty,
},
)
def _grade_answer(self, answer: str, expected: str) -> Tuple[bool, float, str]:
"""
Grade the submitted answer and return normalized score (0.0-1.0).
This grader produces scores between 0.0-1.0 regardless of difficulty:
- 1.0: Perfect answer
- 0.5-0.9: Partial credit (close, some correct elements)
- 0.1-0.4: Format correct but values wrong
- 0.0: Completely incorrect
Args:
answer: The agent's submitted answer
expected: The expected correct answer
Returns:
Tuple of (is_correct, normalized_score, feedback)
"""
answer_clean = answer.strip().lower()
expected_clean = expected.strip().lower()
# Exact match = 1.0
if answer_clean == expected_clean:
return True, 1.0, "✓ Correct! Well done."
# Start evaluating partial credit
score = 0.0
feedback = "✗ Incorrect."
# Check for numeric list answers (comma-separated numbers)
try:
if ',' in expected_clean or expected_clean.replace('-', '').isdigit():
expected_nums = [int(x.strip()) for x in expected_clean.split(',') if x.strip()]
answer_nums = [int(x.strip()) for x in answer_clean.split(',') if x.strip()]
if len(expected_nums) == len(answer_nums):
# Calculate percentage of correct values
correct_count = sum(1 for e, a in zip(expected_nums, answer_nums) if e == a)
score = correct_count / len(expected_nums)
if score >= 0.8:
feedback = f"⚡ Very close! {int(score*100)}% correct values."
elif score >= 0.5:
feedback = f"⚡ Partial credit: {int(score*100)}% correct values."
elif score > 0:
feedback = f"⚡ Some correct: {int(score*100)}%. Review the problem."
elif len(answer_nums) > 0:
# Wrong length but has numbers - give format credit
score = 0.2
feedback = "⚡ Format is numeric, but count/values are wrong."
except (ValueError, AttributeError):
# Not a numeric answer, try string-based grading
pass
# String similarity for non-numeric answers
if score == 0.0:
# Check length similarity
len_ratio = min(len(answer_clean), len(expected_clean)) / max(len(answer_clean), len(expected_clean), 1)
# Character overlap
set_overlap = len(set(answer_clean) & set(expected_clean)) / max(len(set(expected_clean)), 1)
# Combine metrics
similarity = (len_ratio * 0.3 + set_overlap * 0.7)
if similarity >= 0.7:
score = 0.6
feedback = f"⚡ Close! Similar to expected answer ({int(similarity*100)}% match)."
elif similarity >= 0.4:
score = 0.3
feedback = f"⚡ Some similarity ({int(similarity*100)}%). Review requirements."
elif ',' in expected and ',' in answer_clean:
# Has comma format like expected
score = 0.1
feedback = "⚡ Correct format style, but content is incorrect."
return False, score, feedback
def _calculate_reward(self, is_correct: bool, normalized_score: float) -> float:
"""
Calculate reward by applying difficulty multipliers to normalized grader scores.
The grader produces normalized scores (0.0-1.0), which are then scaled by difficulty:
- Easy: 1x multiplier
- Medium: 2x multiplier
- Hard: 5x multiplier
Args:
is_correct: Whether the answer was fully correct (score = 1.0)
normalized_score: Grader score between 0.0-1.0
Returns:
The calculated reward (scaled by difficulty and bonuses)
"""
# Difficulty multipliers
multipliers = {
"easy": 1.0,
"medium": 2.0,
"hard": 5.0,
}
base_multiplier = multipliers[self._difficulty]
if is_correct:
# Perfect score: full multiplier
reward = base_multiplier * 1.0
# Streak bonus for 3+ consecutive correct answers
if self._current_streak >= 3:
reward += 0.5
elif normalized_score > 0:
# Partial credit: scale the normalized score by difficulty
reward = base_multiplier * normalized_score
# Reduce partial rewards slightly for easy problems
if self._difficulty == "easy":
reward *= 0.5
else:
# Complete failure
# Small penalty on hard problems to discourage random guessing
reward = -0.3 if self._difficulty == "hard" else 0.0
return reward
def _advance_to_next_problem(self):
"""Advance to the next problem, increasing difficulty as needed."""
# Move to next test case in current problem
self._current_test_case_idx += 1
# If completed all test cases, select new problem
if self._current_test_case_idx >= len(self._current_problem["test_cases"]):
self._current_test_case_idx = 0
# Increase difficulty based on problems solved
if self._problems_solved >= 8 and self._difficulty != "hard":
self._difficulty = "hard"
elif self._problems_solved >= 4 and self._difficulty == "easy":
self._difficulty = "medium"
# Select new random problem from current difficulty
self._current_problem = random.choice(PROBLEMS[self._difficulty])
@property
def state(self) -> State:
"""
Get the current environment state.
Returns:
Current State with episode_id and step_count
"""
return self._state
|