codefix-env 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codefix_env/models.py ADDED
@@ -0,0 +1,234 @@
1
+ """
2
+ CodeFix-Env Data Models
3
+ =======================
4
+ All Pydantic v2 models for Actions, Observations, State, and Tasks.
5
+ Strongly typed — no `Any`, no raw dicts.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import uuid
11
+ from datetime import datetime
12
+ from enum import Enum
13
+
14
+ from pydantic import BaseModel, Field, field_validator
15
+
16
+ # ─────────────────────────────────────────────
17
+ # Enums
18
+ # ─────────────────────────────────────────────
19
+
20
+
21
+ class ActionType(str, Enum):
22
+ RUN_TESTS = "run_tests"
23
+ EDIT_LINE = "edit_line"
24
+ INSERT_LINE = "insert_line"
25
+ DELETE_LINE = "delete_line"
26
+ GET_HINT = "get_hint"
27
+ SUBMIT_FIX = "submit_fix"
28
+ RESET = "reset"
29
+ VIEW_CODE = "view_code"
30
+
31
+
32
+ class Difficulty(str, Enum):
33
+ EASY = "easy"
34
+ MEDIUM = "medium"
35
+ HARD = "hard"
36
+
37
+
38
+ class BugCategory(str, Enum):
39
+ SYNTAX = "syntax_error"
40
+ LOGIC = "logic_bug"
41
+ OFF_BY_ONE = "off_by_one"
42
+ TYPE_ERROR = "type_error"
43
+ MISSING_IMPORT = "missing_import"
44
+ WRONG_OPERATOR = "wrong_operator"
45
+ RECURSION_BUG = "recursion_bug"
46
+ ALGORITHM_BUG = "algorithm_bug"
47
+ MULTI_FUNCTION = "multi_function"
48
+ RETURN_BUG = "return_bug"
49
+ INDEX_ERROR = "index_error"
50
+ SCOPE_BUG = "scope_bug"
51
+
52
+
53
+ class TerminationReason(str, Enum):
54
+ SOLVED = "solved"
55
+ MAX_STEPS = "max_steps_reached"
56
+ SUBMITTED = "submitted"
57
+ TIMEOUT = "timeout"
58
+
59
+
60
+ # ─────────────────────────────────────────────
61
+ # Action
62
+ # ─────────────────────────────────────────────
63
+
64
+
65
+ class CodeFixAction(BaseModel):
66
+ """
67
+ An action taken by the agent in the CodeFix environment.
68
+
69
+ Examples::
70
+
71
+ CodeFixAction(action_type=ActionType.EDIT_LINE, line_number=5, new_content=" return x + 1")
72
+ CodeFixAction(action_type=ActionType.RUN_TESTS)
73
+ CodeFixAction(action_type=ActionType.SUBMIT_FIX)
74
+ """
75
+
76
+ action_type: ActionType = Field(..., description="Type of action to perform")
77
+ line_number: int | None = Field(
78
+ None, ge=1, description="Target line (1-indexed, for edit/insert/delete)"
79
+ )
80
+ new_content: str | None = Field(None, description="New line content (for edit/insert)")
81
+ reasoning: str | None = Field(
82
+ None, description="Agent's chain-of-thought (logged, not used by env)"
83
+ )
84
+ timestamp: datetime = Field(default_factory=datetime.utcnow)
85
+
86
+ @field_validator("line_number")
87
+ @classmethod
88
+ def line_required_for_edits(cls, v: int | None, info) -> int | None:
89
+ action = info.data.get("action_type")
90
+ if (
91
+ action in (ActionType.EDIT_LINE, ActionType.INSERT_LINE, ActionType.DELETE_LINE)
92
+ and v is None
93
+ ):
94
+ raise ValueError(f"line_number is required for action_type={action}")
95
+ return v
96
+
97
+ @field_validator("new_content")
98
+ @classmethod
99
+ def content_required_for_edits(cls, v: str | None, info) -> str | None:
100
+ action = info.data.get("action_type")
101
+ if action in (ActionType.EDIT_LINE, ActionType.INSERT_LINE) and v is None:
102
+ raise ValueError(f"new_content is required for action_type={action}")
103
+ return v
104
+
105
+ model_config = {"use_enum_values": True}
106
+
107
+
108
+ # ─────────────────────────────────────────────
109
+ # Observation
110
+ # ─────────────────────────────────────────────
111
+
112
+
113
+ class TestResult(BaseModel):
114
+ """Result of a single test case."""
115
+
116
+ name: str
117
+ passed: bool
118
+ output: str = ""
119
+ error: str = ""
120
+ runtime_ms: float = 0.0
121
+
122
+
123
+ class CodeFixObservation(BaseModel):
124
+ """
125
+ Full observation returned after every env.step() or env.reset().
126
+ Contains everything the agent needs to make the next decision.
127
+ """
128
+
129
+ # Code state
130
+ current_code: str = Field(..., description="Current (possibly edited) source code")
131
+ original_code: str = Field(..., description="Original buggy code (never changes)")
132
+ diff: str = Field("", description="Unified diff: original → current")
133
+
134
+ # Test feedback
135
+ test_results: list[TestResult] = Field(default_factory=list)
136
+ test_output: str = Field("", description="Raw stdout/stderr from test runner")
137
+ tests_passed: int = 0
138
+ tests_total: int = 0
139
+ all_tests_pass: bool = False
140
+
141
+ # Scoring
142
+ score_so_far: float = Field(0.0, ge=0.0, le=1.0, description="Cumulative normalised score")
143
+ shaped_reward: float = Field(0.0, description="Dense reward for this step")
144
+
145
+ # Episode metadata
146
+ step_count: int = 0
147
+ max_steps: int = 20
148
+ steps_remaining: int = 20
149
+ done: bool = False
150
+ termination_reason: TerminationReason | None = None
151
+
152
+ # Task info
153
+ task_id: str = ""
154
+ difficulty: Difficulty = Difficulty.MEDIUM
155
+ bug_category: BugCategory = BugCategory.LOGIC
156
+ hint_available: bool = True
157
+ hints_used: int = 0
158
+
159
+ # Agent feedback
160
+ feedback: str = ""
161
+ error_message: str = ""
162
+
163
+
164
+ # ─────────────────────────────────────────────
165
+ # Episode State
166
+ # ─────────────────────────────────────────────
167
+
168
+
169
+ class CodeFixState(BaseModel):
170
+ """Internal episode state (returned by env.state())."""
171
+
172
+ episode_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
173
+ task_id: str = ""
174
+ step_count: int = 0
175
+ max_steps: int = 20
176
+ total_reward: float = 0.0
177
+ action_history: list[dict] = Field(default_factory=list)
178
+ start_time: datetime = Field(default_factory=datetime.utcnow)
179
+ done: bool = False
180
+ solved: bool = False
181
+ hints_used: int = 0
182
+
183
+
184
+ # ─────────────────────────────────────────────
185
+ # Step Result (wraps observation)
186
+ # ─────────────────────────────────────────────
187
+
188
+
189
+ class StepResult(BaseModel):
190
+ """Full result returned by env.step()."""
191
+
192
+ observation: CodeFixObservation
193
+ reward: float
194
+ done: bool
195
+ truncated: bool = False
196
+ info: dict = Field(default_factory=dict)
197
+
198
+
199
+ # ─────────────────────────────────────────────
200
+ # Task Definition
201
+ # ─────────────────────────────────────────────
202
+
203
+
204
+ class TestCase(BaseModel):
205
+ """A single test case for a task."""
206
+
207
+ name: str
208
+ code: str = Field(..., description="Test code (function call + assertion)")
209
+ expected: str = ""
210
+ timeout_s: float = 5.0
211
+
212
+
213
+ class Task(BaseModel):
214
+ """A complete debugging task."""
215
+
216
+ id: str
217
+ title: str
218
+ description: str
219
+ buggy_code: str
220
+ solution_code: str
221
+ test_cases: list[TestCase]
222
+ difficulty: Difficulty
223
+ bug_category: BugCategory
224
+ tags: list[str] = Field(default_factory=list)
225
+ hints: list[str] = Field(default_factory=list)
226
+ max_steps: int = 20
227
+ hint_penalty: float = 0.05
228
+ time_penalty_per_step: float = 0.01
229
+ author: str = "codefix-env"
230
+ created_at: datetime = Field(default_factory=datetime.utcnow)
231
+
232
+ @property
233
+ def num_tests(self) -> int:
234
+ return len(self.test_cases)
codefix_env/rewards.py ADDED
@@ -0,0 +1,157 @@
1
+ """
2
+ Reward Shaping Pipeline
3
+ ========================
4
+ High-level reward computation that combines:
5
+ 1. Rule-based rewards (always active)
6
+ 2. Neural reward model (optional, loaded from checkpoint)
7
+ 3. Reward normalisation across an episode
8
+
9
+ Used by the environment server on every step.
10
+
11
+ NOTE: torch and RewardMLP are imported lazily inside __init__, only when
12
+ a neural_model_path is actually provided. This keeps importing
13
+ codefix_env fast (no torch load) for the common case of rule-based-only
14
+ reward, which matters a lot for sandboxed worker processes that re-import
15
+ the whole package on every spawn (see utils/sandbox.py).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import logging
21
+ import os
22
+ from pathlib import Path
23
+
24
+ from codefix_env.models import ActionType, CodeFixObservation, Task
25
+ from codefix_env.utils.metrics import (
26
+ EpisodeMetrics,
27
+ ScoringConfig,
28
+ compute_diff_score,
29
+ compute_final_score,
30
+ compute_shaped_reward,
31
+ )
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class RewardPipeline:
37
+ """
38
+ Combines rule-based reward with an optional neural reward model.
39
+
40
+ Usage::
41
+
42
+ pipeline = RewardPipeline(cfg=ScoringConfig())
43
+ reward = pipeline.step_reward(prev_obs, curr_obs, action_type, task)
44
+ final = pipeline.episode_reward(curr_obs, task)
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ cfg: ScoringConfig | None = None,
50
+ neural_model_path: str | None = None,
51
+ neural_weight: float = 0.3,
52
+ ):
53
+ self.cfg = cfg if cfg is not None else ScoringConfig()
54
+ self.neural_weight = neural_weight
55
+ self._neural_model = None
56
+
57
+ # Load pre-trained neural reward model if provided.
58
+ # torch + RewardMLP are imported here, not at module top, so the
59
+ # common no-neural-model path never pays torch's import cost.
60
+ if neural_model_path and Path(neural_model_path).exists():
61
+ try:
62
+ import torch
63
+
64
+ from codefix_env.utils.reward_model import RewardMLP
65
+
66
+ self._neural_model = RewardMLP()
67
+ state = torch.load(neural_model_path, map_location="cpu", weights_only=True)
68
+ self._neural_model.load_state_dict(state)
69
+ self._neural_model.eval()
70
+ logger.info("Neural reward model loaded from %s", neural_model_path)
71
+ except Exception as e: # noqa: BLE001
72
+ logger.warning("Failed to load neural reward model: %s", e)
73
+ self._neural_model = None
74
+
75
+ @property
76
+ def uses_neural(self) -> bool:
77
+ return self._neural_model is not None
78
+
79
+ def step_reward(
80
+ self,
81
+ prev_obs: CodeFixObservation,
82
+ curr_obs: CodeFixObservation,
83
+ action_type: ActionType,
84
+ task: Task,
85
+ ) -> float:
86
+ """
87
+ Compute dense reward for a single step.
88
+ Blend of rule-based + optional neural signal.
89
+ """
90
+ rule_reward = compute_shaped_reward(
91
+ prev_passed=prev_obs.tests_passed,
92
+ curr_passed=curr_obs.tests_passed,
93
+ tests_total=curr_obs.tests_total,
94
+ step_count=curr_obs.step_count,
95
+ hints_used=curr_obs.hints_used,
96
+ action_type=action_type.value if hasattr(action_type, "value") else str(action_type),
97
+ cfg=self.cfg,
98
+ )
99
+
100
+ if self._neural_model is None:
101
+ return rule_reward
102
+
103
+ # Neural component
104
+ diff_score = compute_diff_score(curr_obs.original_code, curr_obs.current_code)
105
+ action_id = list(ActionType).index(action_type) if action_type in list(ActionType) else 0
106
+ neural_reward = self._neural_model.predict(
107
+ tests_passed_ratio=curr_obs.tests_passed / max(curr_obs.tests_total, 1),
108
+ step_ratio=curr_obs.step_count / max(task.max_steps, 1),
109
+ hints_used=curr_obs.hints_used,
110
+ diff_score=diff_score,
111
+ prev_score=prev_obs.score_so_far,
112
+ action_type_id=action_id,
113
+ code_len_ratio=len(curr_obs.current_code) / max(len(curr_obs.original_code), 1),
114
+ test_count=curr_obs.tests_total,
115
+ )
116
+
117
+ # Weighted blend: mostly rules, neural provides nuance
118
+ blended = (1.0 - self.neural_weight) * rule_reward + self.neural_weight * neural_reward
119
+ return float(blended)
120
+
121
+ def episode_reward(
122
+ self,
123
+ final_obs: CodeFixObservation,
124
+ task: Task,
125
+ ) -> float:
126
+ """Final episode reward on termination."""
127
+ return compute_final_score(
128
+ tests_passed=final_obs.tests_passed,
129
+ tests_total=final_obs.tests_total,
130
+ step_count=final_obs.step_count,
131
+ hints_used=final_obs.hints_used,
132
+ cfg=self.cfg,
133
+ )
134
+
135
+ def build_metrics(
136
+ self,
137
+ task: Task,
138
+ final_obs: CodeFixObservation,
139
+ total_reward: float,
140
+ ) -> EpisodeMetrics:
141
+ return EpisodeMetrics(
142
+ task_id=task.id,
143
+ solved=final_obs.all_tests_pass,
144
+ final_score=final_obs.score_so_far,
145
+ total_steps=final_obs.step_count,
146
+ hints_used=final_obs.hints_used,
147
+ tests_passed=final_obs.tests_passed,
148
+ tests_total=final_obs.tests_total,
149
+ total_reward=total_reward,
150
+ )
151
+
152
+
153
+ # Singleton default pipeline (no neural model unless env var is set)
154
+ default_pipeline = RewardPipeline(
155
+ cfg=ScoringConfig(),
156
+ neural_model_path=os.environ.get("CODEFIX_REWARD_MODEL_PATH"),
157
+ )
@@ -0,0 +1,75 @@
1
+ """
2
+ Task Registry — Central access point for all CodeFix tasks.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import random
8
+
9
+ from codefix_env.models import Difficulty, Task
10
+ from codefix_env.tasks.easy import EASY_TASKS
11
+ from codefix_env.tasks.hard import HARD_TASKS
12
+ from codefix_env.tasks.medium import MEDIUM_TASKS
13
+
14
+ # ── Master registry ──────────────────────────────────────────────────────────
15
+ ALL_TASKS: dict[str, Task] = {task.id: task for task in EASY_TASKS + MEDIUM_TASKS + HARD_TASKS}
16
+
17
+ TASKS_BY_DIFFICULTY: dict[Difficulty, list[Task]] = {
18
+ Difficulty.EASY: EASY_TASKS,
19
+ Difficulty.MEDIUM: MEDIUM_TASKS,
20
+ Difficulty.HARD: HARD_TASKS,
21
+ }
22
+
23
+
24
+ def load_task(task_id: str) -> Task:
25
+ """Load a task by its exact ID."""
26
+ if task_id not in ALL_TASKS:
27
+ raise KeyError(f"Task '{task_id}' not found. Use list_tasks() to see available tasks.")
28
+ return ALL_TASKS[task_id]
29
+
30
+
31
+ def random_task(
32
+ difficulty: Difficulty | None = None,
33
+ exclude: list[str] | None = None,
34
+ ) -> Task:
35
+ """
36
+ Return a random task, optionally filtered by difficulty and
37
+ excluding already-seen task IDs.
38
+ """
39
+ pool = list(ALL_TASKS.values())
40
+ if difficulty:
41
+ pool = [t for t in pool if t.difficulty == difficulty]
42
+ if exclude:
43
+ pool = [t for t in pool if t.id not in exclude]
44
+ if not pool:
45
+ raise ValueError("No tasks available after applying filters.")
46
+ return random.choice(pool)
47
+
48
+
49
+ def list_tasks(difficulty: Difficulty | None = None) -> list[Task]:
50
+ """List all tasks, optionally filtered by difficulty."""
51
+ if difficulty:
52
+ return TASKS_BY_DIFFICULTY.get(difficulty, [])
53
+ return list(ALL_TASKS.values())
54
+
55
+
56
+ def task_count() -> dict[str, int]:
57
+ """Return count of tasks by difficulty."""
58
+ return {
59
+ "easy": len(EASY_TASKS),
60
+ "medium": len(MEDIUM_TASKS),
61
+ "hard": len(HARD_TASKS),
62
+ "total": len(ALL_TASKS),
63
+ }
64
+
65
+
66
+ __all__ = [
67
+ "ALL_TASKS",
68
+ "EASY_TASKS",
69
+ "MEDIUM_TASKS",
70
+ "HARD_TASKS",
71
+ "load_task",
72
+ "random_task",
73
+ "list_tasks",
74
+ "task_count",
75
+ ]