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.
@@ -0,0 +1,342 @@
1
+ """
2
+ Medium Tasks — Algorithm bugs, logic errors, missing edge cases, scope bugs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from codefix_env.models import BugCategory, Difficulty, Task, TestCase
8
+
9
+ MEDIUM_TASKS: list[Task] = [
10
+ # ── Task M1: Fibonacci — wrong base case ────────────────────────────
11
+ Task(
12
+ id="medium-001-fibonacci",
13
+ title="Fibonacci — Wrong Base Case",
14
+ description="The Fibonacci function has an incorrect base case that makes fib(1) return 0 instead of 1.",
15
+ difficulty=Difficulty.MEDIUM,
16
+ bug_category=BugCategory.ALGORITHM_BUG,
17
+ tags=["recursion", "fibonacci", "base-case"],
18
+ max_steps=15,
19
+ buggy_code="""\
20
+ def fibonacci(n):
21
+ if n <= 0:
22
+ return 0
23
+ if n == 1:
24
+ return 0 # BUG: should be 1
25
+ return fibonacci(n - 1) + fibonacci(n - 2)
26
+ """,
27
+ solution_code="""\
28
+ def fibonacci(n):
29
+ if n <= 0:
30
+ return 0
31
+ if n == 1:
32
+ return 1
33
+ return fibonacci(n - 1) + fibonacci(n - 2)
34
+ """,
35
+ hints=[
36
+ "The first Fibonacci number (n=1) should be 1, not 0.",
37
+ "Fix the return value when n == 1.",
38
+ ],
39
+ test_cases=[
40
+ TestCase(name="test_fib_0", code="assert fibonacci(0) == 0"),
41
+ TestCase(name="test_fib_1", code="assert fibonacci(1) == 1"),
42
+ TestCase(name="test_fib_5", code="assert fibonacci(5) == 5"),
43
+ TestCase(name="test_fib_10", code="assert fibonacci(10) == 55"),
44
+ ],
45
+ ),
46
+ # ── Task M2: Binary search — wrong mid ──────────────────────────────
47
+ Task(
48
+ id="medium-002-binary-search",
49
+ title="Binary Search — Wrong Mid Calculation",
50
+ description="The binary search uses `mid = high / 2` instead of `(low + high) // 2`, causing incorrect results.",
51
+ difficulty=Difficulty.MEDIUM,
52
+ bug_category=BugCategory.ALGORITHM_BUG,
53
+ tags=["binary-search", "algorithm"],
54
+ max_steps=15,
55
+ buggy_code="""\
56
+ def binary_search(arr, target):
57
+ low, high = 0, len(arr) - 1
58
+ while low <= high:
59
+ mid = high // 2
60
+ if arr[mid] == target:
61
+ return mid
62
+ elif arr[mid] < target:
63
+ low = mid + 1
64
+ else:
65
+ high = mid - 1
66
+ return -1
67
+ """,
68
+ solution_code="""\
69
+ def binary_search(arr, target):
70
+ low, high = 0, len(arr) - 1
71
+ while low <= high:
72
+ mid = (low + high) // 2
73
+ if arr[mid] == target:
74
+ return mid
75
+ elif arr[mid] < target:
76
+ low = mid + 1
77
+ else:
78
+ high = mid - 1
79
+ return -1
80
+ """,
81
+ hints=[
82
+ "The midpoint should be calculated from both `low` and `high`, not just `high`.",
83
+ "`mid = (low + high) // 2` is the correct formula.",
84
+ ],
85
+ test_cases=[
86
+ TestCase(name="test_found_middle", code="assert binary_search([1,3,5,7,9], 5) == 2"),
87
+ TestCase(name="test_found_first", code="assert binary_search([1,3,5,7,9], 1) == 0"),
88
+ TestCase(name="test_found_last", code="assert binary_search([1,3,5,7,9], 9) == 4"),
89
+ TestCase(name="test_not_found", code="assert binary_search([1,3,5,7,9], 4) == -1"),
90
+ TestCase(name="test_single", code="assert binary_search([42], 42) == 0"),
91
+ ],
92
+ ),
93
+ # ── Task M3: Palindrome — wrong slice ───────────────────────────────
94
+ Task(
95
+ id="medium-003-palindrome",
96
+ title="Palindrome Check — Wrong Reverse",
97
+ description="The palindrome check reverses incorrectly — it uses `[::-2]` instead of `[::-1]`.",
98
+ difficulty=Difficulty.MEDIUM,
99
+ bug_category=BugCategory.LOGIC,
100
+ tags=["string", "palindrome", "slicing"],
101
+ max_steps=12,
102
+ buggy_code="""\
103
+ def is_palindrome(s):
104
+ s = s.lower().replace(' ', '')
105
+ return s == s[::-2]
106
+ """,
107
+ solution_code="""\
108
+ def is_palindrome(s):
109
+ s = s.lower().replace(' ', '')
110
+ return s == s[::-1]
111
+ """,
112
+ hints=[
113
+ "`[::-2]` reverses every other character. Use `[::-1]` to reverse the full string.",
114
+ ],
115
+ test_cases=[
116
+ TestCase(name="test_racecar", code='assert is_palindrome("racecar") == True'),
117
+ TestCase(name="test_hello", code='assert is_palindrome("hello") == False'),
118
+ TestCase(
119
+ name="test_spaces",
120
+ code='assert is_palindrome("A man a plan a canal Panama") == True',
121
+ ),
122
+ TestCase(name="test_single", code='assert is_palindrome("a") == True'),
123
+ ],
124
+ ),
125
+ # ── Task M4: FizzBuzz wrong order ────────────────────────────────────
126
+ Task(
127
+ id="medium-004-fizzbuzz",
128
+ title="FizzBuzz — Wrong Condition Order",
129
+ description="FizzBuzz checks `n % 3` and `n % 5` before the combined case, causing multiples of 15 to output just 'Fizz'.",
130
+ difficulty=Difficulty.MEDIUM,
131
+ bug_category=BugCategory.LOGIC,
132
+ tags=["fizzbuzz", "condition-order"],
133
+ max_steps=12,
134
+ buggy_code="""\
135
+ def fizzbuzz(n):
136
+ if n % 3 == 0:
137
+ return "Fizz"
138
+ elif n % 5 == 0:
139
+ return "Buzz"
140
+ elif n % 3 == 0 and n % 5 == 0:
141
+ return "FizzBuzz"
142
+ else:
143
+ return str(n)
144
+ """,
145
+ solution_code="""\
146
+ def fizzbuzz(n):
147
+ if n % 3 == 0 and n % 5 == 0:
148
+ return "FizzBuzz"
149
+ elif n % 3 == 0:
150
+ return "Fizz"
151
+ elif n % 5 == 0:
152
+ return "Buzz"
153
+ else:
154
+ return str(n)
155
+ """,
156
+ hints=[
157
+ "The most specific condition (divisible by both 3 and 5) must be checked first.",
158
+ "Move the FizzBuzz check to the top.",
159
+ ],
160
+ test_cases=[
161
+ TestCase(name="test_fizz", code='assert fizzbuzz(9) == "Fizz"'),
162
+ TestCase(name="test_buzz", code='assert fizzbuzz(10) == "Buzz"'),
163
+ TestCase(name="test_fizzbuzz", code='assert fizzbuzz(15) == "FizzBuzz"'),
164
+ TestCase(name="test_number", code='assert fizzbuzz(7) == "7"'),
165
+ TestCase(name="test_30", code='assert fizzbuzz(30) == "FizzBuzz"'),
166
+ ],
167
+ ),
168
+ # ── Task M5: Mutable default argument ───────────────────────────────
169
+ # ── Task M5: Mutable default argument ───────────────────────────────
170
+ Task(
171
+ id="medium-005-mutable-default",
172
+ title="Mutable Default Argument Bug",
173
+ description="Using a mutable list as a default argument causes state to persist across calls.",
174
+ difficulty=Difficulty.MEDIUM,
175
+ bug_category=BugCategory.LOGIC,
176
+ tags=["mutable-default", "python-gotcha"],
177
+ max_steps=12,
178
+ buggy_code="""\
179
+ def append_item(item, items=[]):
180
+ items.append(item)
181
+ return items
182
+ """,
183
+ solution_code="""\
184
+ def append_item(item, items=None):
185
+ if items is None:
186
+ items = []
187
+ items.append(item)
188
+ return items
189
+ """,
190
+ hints=[
191
+ "Default mutable arguments in Python are shared across all calls.",
192
+ "Use `None` as default and create the list inside the function.",
193
+ ],
194
+ test_cases=[
195
+ TestCase(name="test_first_call", code="assert append_item(1) == [1]"),
196
+ TestCase(
197
+ name="test_second_call",
198
+ code="""
199
+ assert append_item(1) == [1]
200
+ assert append_item(2) == [2]""",
201
+ ), # CHANGED from [2] expectation
202
+ TestCase(name="test_with_list", code="assert append_item(3, [10, 20]) == [10, 20, 3]"),
203
+ ],
204
+ ),
205
+ # ── Task M6: Scope bug — global variable ────────────────────────────
206
+ Task(
207
+ id="medium-006-scope-bug",
208
+ title="Scope Bug — Missing Global Declaration",
209
+ description="The counter function tries to modify a global variable without declaring it global.",
210
+ difficulty=Difficulty.MEDIUM,
211
+ bug_category=BugCategory.SCOPE_BUG,
212
+ tags=["scope", "global"],
213
+ max_steps=12,
214
+ buggy_code="""\
215
+ count = 0
216
+
217
+ def increment():
218
+ count += 1
219
+ return count
220
+ """,
221
+ solution_code="""\
222
+ count = 0
223
+
224
+ def increment():
225
+ global count
226
+ count += 1
227
+ return count
228
+ """,
229
+ hints=[
230
+ "To modify a global variable inside a function, you must declare it with `global`.",
231
+ "Add `global count` at the start of the function.",
232
+ ],
233
+ test_cases=[
234
+ TestCase(name="test_increment_once", code="increment(); assert increment() >= 1"),
235
+ TestCase(name="test_returns_int", code="assert isinstance(increment(), int)"),
236
+ ],
237
+ ),
238
+ # ── Task M7: Merge sort wrong merge ─────────────────────────────────
239
+ Task(
240
+ id="medium-007-merge-sort",
241
+ title="Merge Sort — Wrong Comparison in Merge",
242
+ description="The merge step compares using `>` where it should use `<=`, producing a descending sort.",
243
+ difficulty=Difficulty.MEDIUM,
244
+ bug_category=BugCategory.ALGORITHM_BUG,
245
+ tags=["sorting", "merge-sort"],
246
+ max_steps=18,
247
+ buggy_code="""\
248
+ def merge_sort(arr):
249
+ if len(arr) <= 1:
250
+ return arr
251
+ mid = len(arr) // 2
252
+ left = merge_sort(arr[:mid])
253
+ right = merge_sort(arr[mid:])
254
+ return merge(left, right)
255
+
256
+ def merge(left, right):
257
+ result = []
258
+ i = j = 0
259
+ while i < len(left) and j < len(right):
260
+ if left[i] > right[j]: # BUG: should be <=
261
+ result.append(left[i])
262
+ i += 1
263
+ else:
264
+ result.append(right[j])
265
+ j += 1
266
+ result.extend(left[i:])
267
+ result.extend(right[j:])
268
+ return result
269
+ """,
270
+ solution_code="""\
271
+ def merge_sort(arr):
272
+ if len(arr) <= 1:
273
+ return arr
274
+ mid = len(arr) // 2
275
+ left = merge_sort(arr[:mid])
276
+ right = merge_sort(arr[mid:])
277
+ return merge(left, right)
278
+
279
+ def merge(left, right):
280
+ result = []
281
+ i = j = 0
282
+ while i < len(left) and j < len(right):
283
+ if left[i] <= right[j]:
284
+ result.append(left[i])
285
+ i += 1
286
+ else:
287
+ result.append(right[j])
288
+ j += 1
289
+ result.extend(left[i:])
290
+ result.extend(right[j:])
291
+ return result
292
+ """,
293
+ hints=[
294
+ "The merge comparison is backwards — it picks the larger element first.",
295
+ "Change `>` to `<=` in the merge function.",
296
+ ],
297
+ test_cases=[
298
+ TestCase(
299
+ name="test_basic", code="assert merge_sort([3, 1, 4, 1, 5]) == [1, 1, 3, 4, 5]"
300
+ ),
301
+ TestCase(name="test_sorted", code="assert merge_sort([1, 2, 3]) == [1, 2, 3]"),
302
+ TestCase(
303
+ name="test_reverse", code="assert merge_sort([5, 4, 3, 2, 1]) == [1, 2, 3, 4, 5]"
304
+ ),
305
+ TestCase(name="test_single", code="assert merge_sort([1]) == [1]"),
306
+ TestCase(name="test_empty", code="assert merge_sort([]) == []"),
307
+ ],
308
+ ),
309
+ # ── Task M8: Dictionary key error ───────────────────────────────────
310
+ Task(
311
+ id="medium-008-dict-key",
312
+ title="Dictionary — KeyError on Missing Key",
313
+ description="word_count crashes on the second call because it doesn't handle missing keys.",
314
+ difficulty=Difficulty.MEDIUM,
315
+ bug_category=BugCategory.LOGIC,
316
+ tags=["dict", "keyerror"],
317
+ max_steps=12,
318
+ buggy_code="""\
319
+ def word_count(words):
320
+ counts = {}
321
+ for word in words:
322
+ counts[word] += 1
323
+ return counts
324
+ """,
325
+ solution_code="""\
326
+ def word_count(words):
327
+ counts = {}
328
+ for word in words:
329
+ counts[word] = counts.get(word, 0) + 1
330
+ return counts
331
+ """,
332
+ hints=[
333
+ "You can't add 1 to a key that doesn't exist yet.",
334
+ "Use `dict.get(key, 0)` to safely retrieve a value with a default.",
335
+ ],
336
+ test_cases=[
337
+ TestCase(name="test_basic", code="assert word_count(['a','b','a']) == {'a':2,'b':1}"),
338
+ TestCase(name="test_empty", code="assert word_count([]) == {}"),
339
+ TestCase(name="test_single", code="assert word_count(['x']) == {'x':1}"),
340
+ ],
341
+ ),
342
+ ]
@@ -0,0 +1,33 @@
1
+ from codefix_env.utils.metrics import (
2
+ EpisodeMetrics,
3
+ ScoringConfig,
4
+ compute_diff_score,
5
+ compute_final_score,
6
+ compute_shaped_reward,
7
+ compute_test_score,
8
+ )
9
+ from codefix_env.utils.sandbox import ExecutionResult, run_all_tests, run_code
10
+
11
+ __all__ = [
12
+ "run_code",
13
+ "run_all_tests",
14
+ "ExecutionResult",
15
+ "compute_test_score",
16
+ "compute_shaped_reward",
17
+ "compute_final_score",
18
+ "compute_diff_score",
19
+ "ScoringConfig",
20
+ "RewardMLP",
21
+ "EpisodeMetrics",
22
+ ]
23
+
24
+
25
+ def __getattr__(name):
26
+ """Lazy: RewardMLP moved to reward_model.py (torch-only module) so
27
+ importing codefix_env.utils doesn't force torch to load. See
28
+ codefix_env/__init__.py for the matching top-level lazy accessor."""
29
+ if name == "RewardMLP":
30
+ from codefix_env.utils.reward_model import RewardMLP
31
+
32
+ return RewardMLP
33
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,165 @@
1
+ """
2
+ Metrics & Scoring
3
+ ==================
4
+ All reward and score computation for CodeFix-Env.
5
+
6
+ Design:
7
+ - Base reward = fraction of tests passing
8
+ - Shaped reward = dense signal per step (partial credit)
9
+ - Penalties = hint usage, extra steps, time
10
+ - Neural reward model (optional) = learned reward via PyTorch MLP,
11
+ see reward_model.py (kept separate so importing this file never
12
+ requires torch).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import math
18
+ from dataclasses import dataclass
19
+
20
+ # ─────────────────────────────────────────────
21
+ # Score Config
22
+ # ─────────────────────────────────────────────
23
+
24
+
25
+ @dataclass
26
+ class ScoringConfig:
27
+ """Hyper-parameters for reward computation."""
28
+
29
+ max_steps: int = 20
30
+ hint_penalty: float = 0.05 # per hint used
31
+ step_penalty: float = 0.01 # per extra step beyond min
32
+ solve_bonus: float = 0.20 # bonus for solving all tests
33
+ partial_credit_alpha: float = 0.5 # weight of partial progress in shaped reward
34
+ time_decay_gamma: float = 0.99 # exponential decay per step
35
+
36
+
37
+ # ─────────────────────────────────────────────
38
+ # Rule-Based Rewards
39
+ # ─────────────────────────────────────────────
40
+
41
+
42
+ def compute_test_score(tests_passed: int, tests_total: int) -> float:
43
+ """Fraction of tests passing. Core task signal."""
44
+ if tests_total == 0:
45
+ return 0.0
46
+ return tests_passed / tests_total
47
+
48
+
49
+ def compute_shaped_reward(
50
+ prev_passed: int,
51
+ curr_passed: int,
52
+ tests_total: int,
53
+ step_count: int,
54
+ hints_used: int,
55
+ action_type: str,
56
+ cfg: ScoringConfig | None = None,
57
+ ) -> float:
58
+ if cfg is None:
59
+ cfg = ScoringConfig()
60
+ """
61
+ Dense, shaped reward for a single step.
62
+
63
+ Positive signal:
64
+ - Progress: new tests passing → reward proportional to delta
65
+ - Solve bonus: all tests pass
66
+
67
+ Negative signal:
68
+ - Regression: tests were broken
69
+ - Hint penalty
70
+ - Step penalty for idle actions (run_tests without changes)
71
+ """
72
+ reward = 0.0
73
+
74
+ if tests_total > 0:
75
+ delta = (curr_passed - prev_passed) / tests_total
76
+ # Progress signal with partial credit
77
+ reward += delta * (1.0 + cfg.partial_credit_alpha)
78
+
79
+ # Solve bonus
80
+ if curr_passed == tests_total and tests_total > 0:
81
+ reward += cfg.solve_bonus
82
+
83
+ # Hint penalty
84
+ reward -= hints_used * cfg.hint_penalty
85
+
86
+ # Time decay: later steps get slightly less reward
87
+ reward *= cfg.time_decay_gamma**step_count
88
+
89
+ return float(reward)
90
+
91
+
92
+ def compute_final_score(
93
+ tests_passed: int,
94
+ tests_total: int,
95
+ step_count: int,
96
+ hints_used: int,
97
+ cfg: ScoringConfig | None = None,
98
+ ) -> float:
99
+ if cfg is None:
100
+ cfg = ScoringConfig()
101
+ """
102
+ Final episode score in [0, 1].
103
+
104
+ Full solution at minimum steps = 1.0
105
+ Partial solutions penalised by steps and hints.
106
+ """
107
+ if tests_total == 0:
108
+ return 0.0
109
+
110
+ base = tests_passed / tests_total
111
+
112
+ # Step efficiency: solved in fewer steps → higher score
113
+ # Normalised to [0, 1]: 1.0 if solved in 1 step, 0.5 at max_steps/2
114
+ step_factor = math.exp(-cfg.step_penalty * max(0, step_count - 1))
115
+
116
+ hint_deduction = hints_used * cfg.hint_penalty
117
+
118
+ score = base * step_factor - hint_deduction
119
+ return float(max(0.0, min(1.0, score)))
120
+
121
+
122
+ def compute_diff_score(original: str, current: str) -> float:
123
+ """
124
+ Compute how much the code has changed from the original.
125
+ Returns 0.0 (unchanged) to 1.0 (completely different).
126
+ Used as a signal to detect agent activity.
127
+ """
128
+ orig_lines = set(original.strip().splitlines())
129
+ curr_lines = set(current.strip().splitlines())
130
+ if not orig_lines:
131
+ return 0.0
132
+ jaccard = len(orig_lines & curr_lines) / len(orig_lines | curr_lines)
133
+ return 1.0 - jaccard
134
+
135
+
136
+ @dataclass
137
+ class EpisodeMetrics:
138
+ """Summary metrics for a completed episode — logged to W&B / stdout."""
139
+
140
+ task_id: str
141
+ solved: bool
142
+ final_score: float
143
+ total_steps: int
144
+ hints_used: int
145
+ tests_passed: int
146
+ tests_total: int
147
+ total_reward: float
148
+ efficiency: float = 0.0 # solved? then steps_used / max_steps, else 0
149
+
150
+ def __post_init__(self):
151
+ if self.solved and self.total_steps > 0:
152
+ self.efficiency = 1.0 - (self.total_steps / 20.0)
153
+
154
+ def to_dict(self) -> dict:
155
+ return {
156
+ "task_id": self.task_id,
157
+ "solved": self.solved,
158
+ "final_score": round(self.final_score, 4),
159
+ "total_steps": self.total_steps,
160
+ "hints_used": self.hints_used,
161
+ "tests_passed": self.tests_passed,
162
+ "tests_total": self.tests_total,
163
+ "total_reward": round(self.total_reward, 4),
164
+ "efficiency": round(self.efficiency, 4),
165
+ }
@@ -0,0 +1,79 @@
1
+ """
2
+ Neural Reward Model (PyTorch)
3
+ ==============================
4
+ Learned reward model for CodeFix-Env, kept in its own module so that
5
+ importing codefix_env's core (env, sandbox, tasks) never pays torch's
6
+ import cost. torch only loads when someone actually uses RewardMLP.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+ import torch.nn as nn
13
+
14
+
15
+ class RewardMLP(nn.Module):
16
+ """
17
+ Learned reward model for CodeFix-Env.
18
+
19
+ Input features (8-dim):
20
+ [tests_passed_ratio, step_ratio, hints_used, diff_score,
21
+ prev_score, action_type_id, code_len_ratio, test_count]
22
+
23
+ Output: scalar reward in [0, 1] (sigmoid)
24
+
25
+ Train with human feedback or RL traces to replace/augment rule-based reward.
26
+ """
27
+
28
+ INPUT_DIM = 8
29
+ HIDDEN_DIM = 64
30
+ OUTPUT_DIM = 1
31
+
32
+ def __init__(self, hidden_dim: int = HIDDEN_DIM):
33
+ super().__init__()
34
+ self.net = nn.Sequential(
35
+ nn.Linear(self.INPUT_DIM, hidden_dim),
36
+ nn.LayerNorm(hidden_dim),
37
+ nn.ReLU(),
38
+ nn.Dropout(0.1),
39
+ nn.Linear(hidden_dim, hidden_dim),
40
+ nn.LayerNorm(hidden_dim),
41
+ nn.ReLU(),
42
+ nn.Dropout(0.1),
43
+ nn.Linear(hidden_dim, self.OUTPUT_DIM),
44
+ nn.Sigmoid(),
45
+ )
46
+
47
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
48
+ """x: (batch, INPUT_DIM) → (batch, 1) rewards"""
49
+ return self.net(x)
50
+
51
+ def predict(
52
+ self,
53
+ tests_passed_ratio: float,
54
+ step_ratio: float,
55
+ hints_used: int,
56
+ diff_score: float,
57
+ prev_score: float,
58
+ action_type_id: int,
59
+ code_len_ratio: float,
60
+ test_count: int,
61
+ ) -> float:
62
+ """Convenience: predict a single reward scalar."""
63
+ features = torch.tensor(
64
+ [
65
+ [
66
+ tests_passed_ratio,
67
+ step_ratio,
68
+ float(hints_used),
69
+ diff_score,
70
+ prev_score,
71
+ float(action_type_id) / 7.0, # normalise 0..7
72
+ code_len_ratio,
73
+ float(test_count) / 10.0,
74
+ ]
75
+ ],
76
+ dtype=torch.float32,
77
+ )
78
+ with torch.no_grad():
79
+ return self.forward(features).item()