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