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/env.py ADDED
@@ -0,0 +1,503 @@
1
+ """
2
+ CodeFixEnvironment — Core Server-Side Environment
3
+ ===================================================
4
+ Implements the OpenEnv-compatible interface: reset(), step(), state().
5
+
6
+ Features:
7
+ - Full action validation
8
+ - Sandbox code execution per step
9
+ - Dense shaped rewards
10
+ - Diff tracking
11
+ - Hint system with penalty
12
+ - Episode state management
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import difflib
18
+ import uuid
19
+ from copy import deepcopy
20
+
21
+ import structlog
22
+
23
+ from codefix_env.models import (
24
+ ActionType,
25
+ CodeFixAction,
26
+ CodeFixObservation,
27
+ CodeFixState,
28
+ Difficulty,
29
+ StepResult,
30
+ Task,
31
+ TerminationReason,
32
+ TestResult,
33
+ )
34
+ from codefix_env.rewards import RewardPipeline, default_pipeline
35
+ from codefix_env.tasks import load_task, random_task
36
+ from codefix_env.utils.metrics import compute_final_score, compute_test_score
37
+ from codefix_env.utils.sandbox import ExecutionResult, run_all_tests
38
+
39
+ logger = structlog.get_logger(__name__)
40
+
41
+
42
+ class CodeFixEnvironment:
43
+ """
44
+ Server-side RL environment for automated code debugging.
45
+
46
+ Gymnasium-compatible interface:
47
+ obs = env.reset(task_id="easy-001-missing-return")
48
+ result: StepResult = env.step(action)
49
+ state: CodeFixState = env.state()
50
+
51
+ The environment is stateful (one episode at a time per instance).
52
+ For parallel training, spawn one instance per worker.
53
+ """
54
+
55
+ def __init__(
56
+ self,
57
+ reward_pipeline: RewardPipeline = default_pipeline,
58
+ default_difficulty: Difficulty = Difficulty.MEDIUM,
59
+ max_steps: int = 20,
60
+ ):
61
+ self.reward_pipeline = reward_pipeline
62
+ self.default_difficulty = default_difficulty
63
+ self.default_max_steps = max_steps
64
+
65
+ # Episode state (initialised on reset)
66
+ self._task: Task | None = None
67
+ self._state: CodeFixState | None = None
68
+ self._obs: CodeFixObservation | None = None
69
+ self._current_code: str = ""
70
+ self._prev_obs: CodeFixObservation | None = None
71
+
72
+ # ── Public API ────────────────────────────────────────────────────────────
73
+
74
+ def reset(
75
+ self,
76
+ task_id: str | None = None,
77
+ difficulty: Difficulty | None = None,
78
+ seed: int | None = None,
79
+ ) -> CodeFixObservation:
80
+ """
81
+ Start a new episode.
82
+
83
+ Args:
84
+ task_id: Specific task to load. If None, random task is chosen.
85
+ difficulty: Filter random task by difficulty.
86
+ seed: Random seed (for reproducibility).
87
+
88
+ Returns:
89
+ Initial CodeFixObservation.
90
+ """
91
+ if seed is not None:
92
+ import random
93
+
94
+ random.seed(seed)
95
+
96
+ # Load task
97
+ if task_id:
98
+ self._task = load_task(task_id)
99
+ else:
100
+ self._task = random_task(difficulty=difficulty or self.default_difficulty)
101
+
102
+ # Initialise episode
103
+ # constructor max_steps is a hard cap: min(constructor_cap, task_default)
104
+ # e.g. CodeFixEnvironment(max_steps=3) will always truncate at 3 steps
105
+ task_max = self._task.max_steps or self.default_max_steps
106
+ effective_max = min(self.default_max_steps, task_max)
107
+ self._current_code = self._task.buggy_code
108
+ self._state = CodeFixState(
109
+ episode_id=str(uuid.uuid4()),
110
+ task_id=self._task.id,
111
+ max_steps=effective_max,
112
+ )
113
+
114
+ # Build initial observation (no tests run yet)
115
+ self._obs = self._build_observation(
116
+ tests_run=False,
117
+ test_results=[],
118
+ shaped_reward=0.0,
119
+ feedback=f"Episode started. Task: '{self._task.title}'\n{self._task.description}",
120
+ )
121
+ self._prev_obs = deepcopy(self._obs)
122
+
123
+ logger.info(
124
+ "episode_reset",
125
+ episode_id=self._state.episode_id,
126
+ task_id=self._task.id,
127
+ difficulty=self._task.difficulty,
128
+ )
129
+ return self._obs
130
+
131
+ def step(self, action: CodeFixAction) -> StepResult:
132
+ """
133
+ Execute one action in the environment.
134
+
135
+ Returns StepResult with (observation, reward, done, info).
136
+ """
137
+ if self._state is None or self._task is None:
138
+ raise RuntimeError("Call reset() before step().")
139
+ # Allow GET_HINT even on a done episode (graceful feedback, no crash)
140
+ # use_enum_values=True means action_type is stored as plain string not enum member
141
+ action_type_str = (
142
+ action.action_type if isinstance(action.action_type, str) else action.action_type.value
143
+ )
144
+ if self._state.done and action_type_str != ActionType.GET_HINT.value:
145
+ raise RuntimeError("Episode is done. Call reset() to start a new episode.")
146
+
147
+ self._prev_obs = deepcopy(self._obs)
148
+ self._state.step_count += 1
149
+
150
+ # Dispatch action
151
+ action_type = ActionType(action.action_type)
152
+ try:
153
+ obs, immediate_reward = self._dispatch(action_type, action)
154
+ except Exception as e: # noqa: BLE001
155
+ obs = self._build_observation(
156
+ tests_run=False,
157
+ test_results=[],
158
+ shaped_reward=0.0,
159
+ feedback=f"⚠️ Error executing action: {e}",
160
+ error_message=str(e),
161
+ )
162
+ immediate_reward = -0.05
163
+
164
+ # Record in state
165
+ self._state.action_history.append(
166
+ {
167
+ "step": self._state.step_count,
168
+ "action_type": action_type.value,
169
+ "line_number": action.line_number,
170
+ "reward": immediate_reward,
171
+ }
172
+ )
173
+ self._state.total_reward += immediate_reward
174
+
175
+ # Update obs ref
176
+ self._obs = obs
177
+
178
+ # Check terminal conditions
179
+ done, truncated, reason = self._check_terminal(obs)
180
+ if done or truncated:
181
+ self._state.done = True
182
+ self._state.solved = obs.all_tests_pass
183
+ final_score = compute_final_score(
184
+ obs.tests_passed,
185
+ obs.tests_total,
186
+ self._state.step_count,
187
+ self._state.hints_used,
188
+ self.reward_pipeline.cfg,
189
+ )
190
+ obs.score_so_far = final_score
191
+ obs.done = True
192
+ obs.termination_reason = reason
193
+ metrics = self.reward_pipeline.build_metrics(self._task, obs, self._state.total_reward)
194
+ logger.info("episode_done", **metrics.to_dict())
195
+
196
+ result = StepResult(
197
+ observation=obs,
198
+ reward=immediate_reward,
199
+ done=done or truncated,
200
+ truncated=truncated,
201
+ info={
202
+ "episode_id": self._state.episode_id,
203
+ "task_id": self._task.id,
204
+ "total_reward": self._state.total_reward,
205
+ },
206
+ )
207
+ return result
208
+
209
+ def state(self) -> CodeFixState:
210
+ """Return internal episode state (non-destructive)."""
211
+ if self._state is None:
212
+ raise RuntimeError("Call reset() first.")
213
+ return deepcopy(self._state)
214
+
215
+ # ── Action Handlers ───────────────────────────────────────────────────────
216
+
217
+ def _dispatch(
218
+ self,
219
+ action_type: ActionType,
220
+ action: CodeFixAction,
221
+ ) -> tuple[CodeFixObservation, float]:
222
+ """Route action to its handler. Returns (obs, reward)."""
223
+
224
+ if action_type == ActionType.RUN_TESTS:
225
+ return self._action_run_tests()
226
+
227
+ elif action_type == ActionType.EDIT_LINE:
228
+ return self._action_edit_line(action.line_number, action.new_content or "")
229
+
230
+ elif action_type == ActionType.INSERT_LINE:
231
+ return self._action_insert_line(action.line_number, action.new_content or "")
232
+
233
+ elif action_type == ActionType.DELETE_LINE:
234
+ return self._action_delete_line(action.line_number)
235
+
236
+ elif action_type == ActionType.GET_HINT:
237
+ return self._action_get_hint()
238
+
239
+ elif action_type == ActionType.SUBMIT_FIX:
240
+ return self._action_submit()
241
+
242
+ elif action_type == ActionType.VIEW_CODE:
243
+ return self._action_view_code()
244
+
245
+ else:
246
+ return (
247
+ self._build_observation(
248
+ tests_run=False,
249
+ test_results=[],
250
+ shaped_reward=0.0,
251
+ feedback=f"Unknown action: {action_type}",
252
+ error_message="UnknownAction",
253
+ ),
254
+ -0.02,
255
+ )
256
+
257
+ def _action_run_tests(self) -> tuple[CodeFixObservation, float]:
258
+ results = run_all_tests(self._current_code, self._task.test_cases)
259
+ test_results = self._parse_results(results)
260
+ passed = sum(1 for r in test_results if r.passed)
261
+ obs = self._build_observation(
262
+ tests_run=True,
263
+ test_results=test_results,
264
+ shaped_reward=0.0,
265
+ feedback=f"Tests run: {passed}/{len(test_results)} passing.",
266
+ )
267
+ reward = self.reward_pipeline.step_reward(
268
+ self._prev_obs, obs, ActionType.RUN_TESTS, self._task
269
+ )
270
+ obs.shaped_reward = reward
271
+ return obs, reward
272
+
273
+ def _action_edit_line(
274
+ self, line_num: int, new_content: str
275
+ ) -> tuple[CodeFixObservation, float]:
276
+ lines = self._current_code.splitlines(keepends=True)
277
+ if line_num < 1 or line_num > len(lines):
278
+ obs = self._build_observation(
279
+ tests_run=False,
280
+ test_results=[],
281
+ shaped_reward=-0.02,
282
+ feedback=f"Invalid line number {line_num}. Code has {len(lines)} lines.",
283
+ error_message="InvalidLineNumber",
284
+ )
285
+ return obs, -0.02
286
+
287
+ # Preserve original line ending
288
+ ending = "\n" if not new_content.endswith("\n") else ""
289
+ lines[line_num - 1] = new_content + ending
290
+ self._current_code = "".join(lines)
291
+
292
+ obs = self._build_observation(
293
+ tests_run=False,
294
+ test_results=[],
295
+ shaped_reward=0.0,
296
+ feedback=f"Line {line_num} edited.",
297
+ )
298
+ reward = self.reward_pipeline.step_reward(
299
+ self._prev_obs, obs, ActionType.EDIT_LINE, self._task
300
+ )
301
+ obs.shaped_reward = reward
302
+ return obs, reward
303
+
304
+ def _action_insert_line(
305
+ self, after_line: int, content: str
306
+ ) -> tuple[CodeFixObservation, float]:
307
+ lines = self._current_code.splitlines(keepends=True)
308
+ idx = min(after_line, len(lines))
309
+ if not content.endswith("\n"):
310
+ content += "\n"
311
+ lines.insert(idx, content)
312
+ self._current_code = "".join(lines)
313
+ obs = self._build_observation(
314
+ tests_run=False,
315
+ test_results=[],
316
+ shaped_reward=0.0,
317
+ feedback=f"Line inserted after line {after_line}.",
318
+ )
319
+ reward = self.reward_pipeline.step_reward(
320
+ self._prev_obs, obs, ActionType.INSERT_LINE, self._task
321
+ )
322
+ obs.shaped_reward = reward
323
+ return obs, reward
324
+
325
+ def _action_delete_line(self, line_num: int) -> tuple[CodeFixObservation, float]:
326
+ lines = self._current_code.splitlines(keepends=True)
327
+ if line_num < 1 or line_num > len(lines):
328
+ obs = self._build_observation(
329
+ tests_run=False,
330
+ test_results=[],
331
+ shaped_reward=-0.02,
332
+ feedback=f"Invalid line number {line_num}.",
333
+ error_message="InvalidLineNumber",
334
+ )
335
+ return obs, -0.02
336
+ del lines[line_num - 1]
337
+ self._current_code = "".join(lines)
338
+ obs = self._build_observation(
339
+ tests_run=False,
340
+ test_results=[],
341
+ shaped_reward=0.0,
342
+ feedback=f"Line {line_num} deleted.",
343
+ )
344
+ reward = self.reward_pipeline.step_reward(
345
+ self._prev_obs, obs, ActionType.DELETE_LINE, self._task
346
+ )
347
+ obs.shaped_reward = reward
348
+ return obs, reward
349
+
350
+ def _action_get_hint(self) -> tuple[CodeFixObservation, float]:
351
+ hints_used = self._state.hints_used
352
+ hints = self._task.hints
353
+ if not hints:
354
+ feedback = "No hints available for this task."
355
+ elif hints_used < len(hints):
356
+ feedback = f"💡 Hint {hints_used + 1}/{len(hints)}: {hints[hints_used]}"
357
+ self._state.hints_used += 1
358
+ else:
359
+ feedback = "No more hints available."
360
+
361
+ penalty = -self._task.hint_penalty if hints_used < len(hints) else 0.0
362
+ obs = self._build_observation(
363
+ tests_run=False,
364
+ test_results=[],
365
+ shaped_reward=penalty,
366
+ feedback=feedback,
367
+ )
368
+ return obs, penalty
369
+
370
+ def _action_submit(self) -> tuple[CodeFixObservation, float]:
371
+ """Run all tests and compute final reward."""
372
+ results = run_all_tests(self._current_code, self._task.test_cases)
373
+ test_results = self._parse_results(results)
374
+ passed = sum(1 for r in test_results if r.passed)
375
+
376
+ obs = self._build_observation(
377
+ tests_run=True,
378
+ test_results=test_results,
379
+ shaped_reward=0.0,
380
+ feedback=(
381
+ f"✅ Submitted! {passed}/{len(test_results)} tests passing."
382
+ if passed == len(test_results)
383
+ else f"❌ Submitted with {passed}/{len(test_results)} tests passing."
384
+ ),
385
+ )
386
+ obs.done = True
387
+ obs.termination_reason = TerminationReason.SUBMITTED
388
+
389
+ reward = self.reward_pipeline.episode_reward(obs, self._task)
390
+ obs.shaped_reward = reward
391
+ obs.score_so_far = reward
392
+ self._state.done = True
393
+ return obs, reward
394
+
395
+ def _action_view_code(self) -> tuple[CodeFixObservation, float]:
396
+ obs = self._build_observation(
397
+ tests_run=False,
398
+ test_results=[],
399
+ shaped_reward=0.0,
400
+ feedback="Code viewed.",
401
+ )
402
+ return obs, 0.0
403
+
404
+ # ── Helpers ───────────────────────────────────────────────────────────────
405
+
406
+ def _build_observation(
407
+ self,
408
+ tests_run: bool,
409
+ test_results: list[TestResult],
410
+ shaped_reward: float,
411
+ feedback: str = "",
412
+ error_message: str = "",
413
+ ) -> CodeFixObservation:
414
+ """Construct a full CodeFixObservation from current state."""
415
+ assert self._task and self._state
416
+
417
+ passed = (
418
+ sum(1 for r in test_results if r.passed)
419
+ if tests_run
420
+ else (self._obs.tests_passed if self._obs else 0)
421
+ )
422
+ total = len(self._task.test_cases)
423
+ all_pass = passed == total and tests_run
424
+ score = (
425
+ compute_test_score(passed, total)
426
+ if tests_run
427
+ else (self._obs.score_so_far if self._obs else 0.0)
428
+ )
429
+
430
+ # Build unified diff
431
+ diff = "".join(
432
+ difflib.unified_diff(
433
+ self._task.buggy_code.splitlines(keepends=True),
434
+ self._current_code.splitlines(keepends=True),
435
+ fromfile="original.py",
436
+ tofile="current.py",
437
+ )
438
+ )
439
+
440
+ return CodeFixObservation(
441
+ current_code=self._current_code,
442
+ original_code=self._task.buggy_code,
443
+ diff=diff,
444
+ test_results=test_results,
445
+ test_output=(
446
+ "\n".join(
447
+ f"[{'✓' if r.passed else '✗'}] {r.name}: {r.output or r.error}"
448
+ for r in test_results
449
+ )
450
+ if test_results
451
+ else ""
452
+ ),
453
+ tests_passed=passed,
454
+ tests_total=total,
455
+ all_tests_pass=all_pass,
456
+ score_so_far=score,
457
+ shaped_reward=shaped_reward,
458
+ step_count=self._state.step_count,
459
+ max_steps=self._state.max_steps,
460
+ steps_remaining=max(0, self._state.max_steps - self._state.step_count),
461
+ done=False,
462
+ task_id=self._task.id,
463
+ difficulty=self._task.difficulty,
464
+ bug_category=self._task.bug_category,
465
+ hints_used=self._state.hints_used,
466
+ hint_available=self._state.hints_used < len(self._task.hints),
467
+ feedback=feedback,
468
+ error_message=error_message,
469
+ )
470
+
471
+ def _parse_results(self, exec_results: list[ExecutionResult]) -> list[TestResult]:
472
+ """Convert raw ExecutionResult list → TestResult list."""
473
+ assert self._task
474
+ results = []
475
+ for tc, er in zip(self._task.test_cases, exec_results, strict=True):
476
+ # Guard: er must be ExecutionResult not a list or other type
477
+ if not isinstance(er, ExecutionResult):
478
+ er = ExecutionResult(
479
+ passed=False,
480
+ exception=f"InternalError: expected ExecutionResult, got {type(er).__name__}",
481
+ )
482
+ results.append(
483
+ TestResult(
484
+ name=tc.name,
485
+ passed=er.passed,
486
+ output=er.stdout[:500] if er.stdout else "",
487
+ error=(er.exception or er.stderr)[:500],
488
+ runtime_ms=er.runtime_ms,
489
+ )
490
+ )
491
+ return results
492
+
493
+ def _check_terminal(
494
+ self, obs: CodeFixObservation
495
+ ) -> tuple[bool, bool, TerminationReason | None]:
496
+ """Returns (done, truncated, reason)."""
497
+ if obs.all_tests_pass:
498
+ return True, False, TerminationReason.SOLVED
499
+ if obs.done:
500
+ return True, False, TerminationReason.SUBMITTED
501
+ if self._state.step_count >= self._state.max_steps:
502
+ return False, True, TerminationReason.MAX_STEPS
503
+ return False, False, None