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/__init__.py +80 -0
- codefix_env/cli.py +83 -0
- codefix_env/client.py +194 -0
- codefix_env/env.py +503 -0
- codefix_env/models.py +234 -0
- codefix_env/rewards.py +157 -0
- codefix_env/tasks/__init__.py +75 -0
- codefix_env/tasks/easy.py +244 -0
- codefix_env/tasks/hard.py +430 -0
- codefix_env/tasks/medium.py +342 -0
- codefix_env/utils/__init__.py +33 -0
- codefix_env/utils/metrics.py +165 -0
- codefix_env/utils/reward_model.py +79 -0
- codefix_env/utils/sandbox.py +392 -0
- codefix_env-0.2.1.dist-info/METADATA +663 -0
- codefix_env-0.2.1.dist-info/RECORD +20 -0
- codefix_env-0.2.1.dist-info/WHEEL +5 -0
- codefix_env-0.2.1.dist-info/entry_points.txt +2 -0
- codefix_env-0.2.1.dist-info/licenses/LICENSE +201 -0
- codefix_env-0.2.1.dist-info/top_level.txt +1 -0
|
@@ -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()
|