Skip to content

Commit 71564da

Browse files
authored
fix(code_gen): apply 1e-6 float tolerance to LCB stdout comparison (#2956)
## What does this PR do? Fixes four sources of **false negatives** in LiveCodeBench grading: correct solutions that were scored Wrong Answer or Runtime Error by the grader rather than by any fault of the model. All four were found by diffing against the Nemotron-Cascade evaluation harness and then confirmed against real rollouts from a 1362-rollout DeepSeek-V4-Flash LCB v6 run. | # | Defect | Was scored | Rollouts affected | |---|---|---|---| | 1 | stdout float comparison required exact `Decimal` equality | Wrong Answer | 12 | | 2 | `sys.stdout.buffer.write` crashed (`StringIO` has no `.buffer`) | Runtime Error | 9 | | 3 | int→str limited to CPython's 4300-digit default | Runtime Error | 0 in this run | | 4 | call-based float **lists** never got tolerance | Wrong Answer | 0 in this run | **Measured effect: 93.39% → 94.93%** (1272 → 1293 of 1362). The Cascade harness independently reports **95.05%** on the same generations, so this closes the gap to within roughly one rollout. ## 1. Float tolerance on stdout comparison `grade_stdio` compared answers token-by-token as exact `Decimal`s. Competitive judges accept 1e-6 absolute or relative error, so a correct solution printing `0.333333` against a reference of `0.333333333333` was a false negative. Real examples recovered from the run: | our output | expected | |---|---| | `6.06449510224597965191` | `6.06449510224597979401` | | `0.076923076923077` | `0.076923076923` | | `17.142857142857142` | `17.142857142857142350` | The call-based path already had `np.allclose` (adopted from Nemotron-Cascade's `code_verifier_utils`), so only stdin/stdout problems were affected — which is exactly where float-output problems live (AtCoder `abc374_d`, `abc375_b`, `abc385_f`). **Integers stay exact.** The comment above this comparison explains why the path used `Decimal` rather than `np.isclose`: ``` ## otherwise gotcha: np.isclose(50000000000000000, 50000000000000001) = True ``` That concern is real, so tolerance applies only to tokens that are genuinely floating-point (containing `.` or an exponent). `50000000000000000` vs `50000000000000001` still fails, with a regression test pinning it. Non-finite values are rejected rather than run through the tolerance branch. Worth noting this is stricter than a `float()`-based comparator: parsing with `float()` silently collapses integers above 2^53, so an off-by-one on a 1e18-scale answer would pass. Keeping `Decimal` avoids that. ## 2. `sys.stdout.buffer.write` support `Capturing` replaced `sys.stdout` with a `StringIO`, which has no `.buffer`, so the standard fast-output idiom raised `AttributeError` and was recorded as a Runtime Error. Capture now goes through a `BytesIO` wrapped in a `write_through` `TextIOWrapper`, so text and byte writes share one stream and keep their relative order. This is the output-side counterpart of the `sys.stdin.buffer` fix in #2824. **9 of the 16 Runtime Errors** in the measured run are exactly this `AttributeError`. ## 3. Large integer output The graded environment kept CPython's default 4300-digit cap on int→str conversion, so printing a large answer raised `ValueError` and was scored a Runtime Error. `import_string` now raises the limit, alongside the existing recursion-limit bump. This did not fire in the measured run but will on any problem with a large integer answer. ## 4. Call-based float lists Only scalar predictions were passed to `np.allclose`, so a returned list of floats within tolerance was scored Wrong Answer. Now compared element-wise when both sides are lists. ## Deliberately not changed Two differences from the Cascade harness alter accept/reject semantics rather than fixing a defect, so they are left for a protocol decision rather than folded in here: - **Case-insensitive Yes/No.** The Cascade harness accepts `YES` against `Yes`; this PR does not. Many judges are genuinely case-sensitive, so matching it silently would be a scoring change, not a fix. - **Memory cap.** `reliability_guard` caps at 4 GB here and is uncapped in the Cascade harness. That accounts for the single `MemoryError` in the measured run. ## Test plan - `resources_servers/code_gen/tests/test_float_tolerance.py` (13 tests): tolerance acceptance, per-token enforcement across a line, the large-integer gotcha, small-integer off-by-one, token-count mismatch, scientific notation, non-finite input. - `resources_servers/code_gen/tests/test_grader_environment.py` (13 tests): `stdout.buffer` writes, text/byte write ordering, `close()` as a no-op, stdout restoration, the int→str limit in both directions, call-based float lists including length mismatch and integer lists. - Full code_gen suite green: **33 passed**. - End-to-end through `run_test`: all previously failing idioms now accepted, `YES`/`Yes` still rejected as intended, and the ten float-tolerance cases unregressed in both directions. - Ruff lint, import order and format clean on both changed files (pinned v0.9.9, matching `.pre-commit-config.yaml`). Scores can only move up, and only where the grader was previously wrong. This also removes false negatives from RL code verification, where a wrongly-rejected correct rollout is a bad training signal. ## Checklist - [x] I have read the [contributing guidelines](https://docs.nvidia.com/nemo/gym/latest/contribute/development-setup). - [x] The change is focused; unrelated "drive-by" edits are tracked as separate issues/PRs. - [x] Tests added or updated and pass locally, or N/A for docs-only / non-code changes (so CI unit/server checks pass when applicable). - [x] Pre-commit checks pass locally (`pre-commit run --all-files`) (so CI lint/format/copyright pass). - [x] All commits have DCO sign-off (`git commit -s`) (so the DCO check passes). --------- Signed-off-by: fgalko <fgalko@nvidia.com>
1 parent ba4d686 commit 71564da

3 files changed

Lines changed: 303 additions & 12 deletions

File tree

resources_servers/code_gen/lcb_integration/testing_util.py

Lines changed: 80 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
from datetime import datetime
2828
from decimal import Decimal
2929
from enum import Enum
30-
from io import BytesIO, StringIO
30+
from io import BytesIO, StringIO, TextIOWrapper
3131

3232
# from pyext import RuntimeModule
3333
from types import ModuleType
@@ -38,7 +38,7 @@
3838
import numpy as np
3939

4040

41-
import_string = "from string import *\nfrom re import *\nfrom datetime import *\nfrom collections import *\nfrom heapq import *\nfrom bisect import *\nfrom copy import *\nfrom math import *\nfrom random import *\nfrom statistics import *\nfrom itertools import *\nfrom functools import *\nfrom operator import *\nfrom io import *\nfrom sys import *\nfrom json import *\nfrom builtins import *\nfrom typing import *\nimport string\nimport re\nimport datetime\nimport collections\nimport heapq\nimport bisect\nimport copy\nimport math\nimport random\nimport statistics\nimport itertools\nimport functools\nimport operator\nimport io\nimport sys\nimport json\nsys.setrecursionlimit(50000)\n"
41+
import_string = "from string import *\nfrom re import *\nfrom datetime import *\nfrom collections import *\nfrom heapq import *\nfrom bisect import *\nfrom copy import *\nfrom math import *\nfrom random import *\nfrom statistics import *\nfrom itertools import *\nfrom functools import *\nfrom operator import *\nfrom io import *\nfrom sys import *\nfrom json import *\nfrom builtins import *\nfrom typing import *\nimport string\nimport re\nimport datetime\nimport collections\nimport heapq\nimport bisect\nimport copy\nimport math\nimport random\nimport statistics\nimport itertools\nimport functools\nimport operator\nimport io\nimport sys\nimport json\nsys.setrecursionlimit(50000)\nsys.set_int_max_str_digits(1 << 20)\n"
4242

4343

4444
def truncatefn(s, length=300):
@@ -77,15 +77,24 @@ def timeout_handler(signum, frame):
7777
class Capturing(list):
7878
def __enter__(self):
7979
self._stdout = sys.stdout
80-
sys.stdout = self._stringio = StringIO()
81-
# Make closing the StringIO a no-op
82-
self._stringio.close = lambda x: 1
80+
self._bytes_buffer = BytesIO()
81+
self._stringio = TextIOWrapper(
82+
self._bytes_buffer,
83+
encoding="utf-8",
84+
newline="",
85+
write_through=True,
86+
)
87+
# Make closing the stream a no-op
88+
self._stringio.close = lambda *args, **kwargs: None
89+
sys.stdout = self._stringio
8390
return self
8491

8592
def __exit__(self, *args):
86-
self.append(self._stringio.getvalue())
87-
del self._stringio # free up some memory
93+
self._stringio.flush()
94+
self.append(self._bytes_buffer.getvalue().decode("utf-8", errors="replace"))
8895
sys.stdout = self._stdout
96+
del self._stringio # free up some memory
97+
del self._bytes_buffer
8998

9099

91100
# Custom mock for sys.stdin that supports buffer attribute
@@ -253,13 +262,63 @@ def convert_line_to_decimals(line: str) -> tuple[bool, list[Decimal]]:
253262
return True, decimal_line
254263

255264

265+
FLOAT_ERROR_TOLERANCE = Decimal("1e-6")
266+
267+
268+
def token_is_floating_point(token: str) -> bool:
269+
return "." in token or "e" in token.lower()
270+
271+
272+
def decimal_tokens_match(expected_token: str, predicted_token: str, expected: Decimal, predicted: Decimal) -> bool:
273+
if expected == predicted:
274+
return True
275+
if not (token_is_floating_point(expected_token) or token_is_floating_point(predicted_token)):
276+
return False
277+
if not (expected.is_finite() and predicted.is_finite()):
278+
return False
279+
absolute_error = abs(expected - predicted)
280+
relative_scale = max(abs(expected), Decimal(1))
281+
return absolute_error <= FLOAT_ERROR_TOLERANCE or absolute_error / relative_scale <= FLOAT_ERROR_TOLERANCE
282+
283+
284+
def decimal_lines_match(
285+
expected_tokens: list[str],
286+
predicted_tokens: list[str],
287+
expected_line: list[Decimal],
288+
predicted_line: list[Decimal],
289+
) -> bool:
290+
if len(expected_line) != len(predicted_line):
291+
return False
292+
return all(
293+
decimal_tokens_match(expected_token, predicted_token, expected, predicted)
294+
for expected_token, predicted_token, expected, predicted in zip(
295+
expected_tokens, predicted_tokens, expected_line, predicted_line
296+
)
297+
)
298+
299+
256300
def get_stripped_lines(val: str):
257301
## you don't want empty lines to add empty list after splitlines!
258302
val = val.strip()
259303

260304
return [val_line.strip() for val_line in val.split("\n")]
261305

262306

307+
def values_are_close(expected, predicted) -> bool:
308+
if expected == predicted:
309+
return True
310+
if isinstance(expected, bool) or isinstance(predicted, bool):
311+
return False
312+
if not (isinstance(expected, float) or isinstance(predicted, float)):
313+
return False
314+
if not isinstance(expected, (int, float, str)) or not isinstance(predicted, (int, float, str)):
315+
return False
316+
try:
317+
return bool(np.allclose(float(predicted), float(expected)))
318+
except (TypeError, ValueError):
319+
return False
320+
321+
263322
def grade_call_based(code: str, all_inputs: list, all_outputs: list, fn_name: str, timeout: int):
264323
# call-based clean up logic
265324
# need to wrap in try-catch logic after to catch the correct errors, but for now this is fine.
@@ -304,10 +363,14 @@ def grade_call_based(code: str, all_inputs: list, all_outputs: list, fn_name: st
304363
except Exception:
305364
pass
306365

307-
try:
308-
tmp_result = tmp_result or (np.allclose(float(prediction), float(gt_out)))
309-
except Exception:
310-
pass
366+
if not tmp_result:
367+
tmp_result = values_are_close(gt_out, prediction)
368+
369+
if not tmp_result and isinstance(prediction, list) and isinstance(gt_out, list):
370+
tmp_result = len(prediction) == len(gt_out) and all(
371+
values_are_close(expected_item, predicted_item)
372+
for predicted_item, expected_item in zip(prediction, gt_out)
373+
)
311374

312375
all_results.append(tmp_result)
313376

@@ -455,7 +518,12 @@ def grade_stdio(
455518
all_results.append(-2)
456519
return all_results, WA_send_args
457520

458-
if decimal_prediction_line == decimal_gtout_line:
521+
if decimal_lines_match(
522+
stripped_gt_out_line.split(),
523+
stripped_prediction_line.split(),
524+
decimal_gtout_line,
525+
decimal_prediction_line,
526+
):
459527
continue
460528

461529
all_results.append(-2)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
"""Tests for floating-point tolerance in stdout answer comparison."""
10+
11+
from __future__ import annotations
12+
13+
from lcb_integration.testing_util import convert_line_to_decimals, decimal_lines_match
14+
15+
16+
def _match(expected: str, predicted: str) -> bool:
17+
expected_ok, expected_line = convert_line_to_decimals(expected)
18+
predicted_ok, predicted_line = convert_line_to_decimals(predicted)
19+
assert expected_ok and predicted_ok
20+
return decimal_lines_match(expected.split(), predicted.split(), expected_line, predicted_line)
21+
22+
23+
class TestFloatsGetTolerance:
24+
"""Judges accept 1e-6 absolute or relative error on floating-point output."""
25+
26+
def test_exact_float_matches(self):
27+
assert _match("0.333333333333", "0.333333333333")
28+
29+
def test_small_relative_error_matches(self):
30+
assert _match("0.333333333333", "0.3333333333")
31+
32+
def test_six_decimal_places_matches_twelve(self):
33+
assert _match("0.333333333333", "0.333333")
34+
35+
def test_relative_error_on_large_magnitude_matches(self):
36+
assert _match("1000000.0", "1000000.0000001")
37+
38+
def test_scientific_notation_matches(self):
39+
assert _match("1e-7", "0.0000001")
40+
41+
def test_error_above_tolerance_fails(self):
42+
assert not _match("0.333333333333", "0.334")
43+
44+
def test_every_token_must_be_within_tolerance(self):
45+
assert _match("1.0 3.0 3.5", "1.0000001 2.9999999 3.5")
46+
assert not _match("1.0 3.0 3.5", "1.0000001 2.5 3.5")
47+
48+
49+
class TestIntegersStayExact:
50+
"""Tolerance must not resurrect the np.isclose large-integer false positive."""
51+
52+
def test_large_integer_off_by_one_fails(self):
53+
assert not _match("50000000000000000", "50000000000000001")
54+
55+
def test_small_integer_off_by_one_fails(self):
56+
assert not _match("41", "42")
57+
58+
def test_equal_integers_match(self):
59+
assert _match("50000000000000000", "50000000000000000")
60+
61+
62+
class TestStructuralMismatches:
63+
def test_token_count_mismatch_fails(self):
64+
assert not _match("1.0", "1.0 2.0")
65+
66+
def test_non_finite_does_not_crash(self):
67+
assert not _match("1.0", "inf")
68+
assert not _match("1.0", "nan")
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
"""Tests for the environment submitted code is graded in: stdout capture and interpreter limits."""
10+
11+
from __future__ import annotations
12+
13+
import sys
14+
15+
import pytest
16+
from lcb_integration.testing_util import (
17+
Capturing,
18+
grade_call_based,
19+
import_string,
20+
values_are_close,
21+
)
22+
23+
24+
class TestStdoutCaptureSupportsBuffer:
25+
"""Submitted code may use the `sys.stdout.buffer.write` fast-output idiom."""
26+
27+
def test_buffer_write_is_captured(self):
28+
with Capturing() as captured:
29+
sys.stdout.buffer.write(b"42\n")
30+
assert captured[0] == "42\n"
31+
32+
def test_text_writes_are_captured(self):
33+
with Capturing() as captured:
34+
print("hello")
35+
assert captured[0] == "hello\n"
36+
37+
def test_text_and_buffer_writes_keep_order(self):
38+
with Capturing() as captured:
39+
print("a")
40+
sys.stdout.buffer.write(b"b\n")
41+
print("c")
42+
assert captured[0] == "a\nb\nc\n"
43+
44+
def test_closing_stdout_does_not_lose_output(self):
45+
with Capturing() as captured:
46+
print("before")
47+
sys.stdout.close()
48+
print("after")
49+
assert captured[0] == "before\nafter\n"
50+
51+
def test_stdout_is_restored(self):
52+
original = sys.stdout
53+
with Capturing():
54+
print("ignored")
55+
assert sys.stdout is original
56+
57+
58+
class TestInterpreterLimits:
59+
"""Competitive answers can be very large integers, which Python refuses to print by default."""
60+
61+
def test_import_string_raises_the_int_to_str_limit(self):
62+
previous = sys.get_int_max_str_digits()
63+
try:
64+
exec(import_string, {})
65+
assert sys.get_int_max_str_digits() >= 1 << 20
66+
assert len(str(10**10000)) == 10001
67+
finally:
68+
sys.set_int_max_str_digits(previous)
69+
70+
def test_default_limit_would_reject_a_large_answer(self):
71+
previous = sys.get_int_max_str_digits()
72+
try:
73+
sys.set_int_max_str_digits(4300)
74+
with pytest.raises(ValueError):
75+
str(10**10000)
76+
finally:
77+
sys.set_int_max_str_digits(previous)
78+
79+
80+
class TestCallBasedFloatLists:
81+
"""A returned list of floats within tolerance must not be scored Wrong Answer."""
82+
83+
def test_float_list_within_tolerance_passes(self):
84+
code = "class Solution:\n def f(self, n):\n return [0.1000000001, 0.2]\n"
85+
results, _metadata = grade_call_based(
86+
code=code, all_inputs=["[1]"], all_outputs=["[0.1, 0.2]"], fn_name="f", timeout=10
87+
)
88+
assert results == [True]
89+
90+
def test_float_list_outside_tolerance_fails(self):
91+
code = "class Solution:\n def f(self, n):\n return [0.5, 0.2]\n"
92+
results, _metadata = grade_call_based(
93+
code=code, all_inputs=["[1]"], all_outputs=["[0.1, 0.2]"], fn_name="f", timeout=10
94+
)
95+
assert results != [True]
96+
97+
def test_float_list_of_different_length_fails(self):
98+
code = "class Solution:\n def f(self, n):\n return [0.1, 0.2, 0.3]\n"
99+
results, _metadata = grade_call_based(
100+
code=code, all_inputs=["[1]"], all_outputs=["[0.1, 0.2]"], fn_name="f", timeout=10
101+
)
102+
assert results != [True]
103+
104+
def test_exact_int_list_still_passes(self):
105+
code = "class Solution:\n def f(self, n):\n return [1, 2, 3]\n"
106+
results, _metadata = grade_call_based(
107+
code=code, all_inputs=["[1]"], all_outputs=["[1, 2, 3]"], fn_name="f", timeout=10
108+
)
109+
assert results == [True]
110+
111+
112+
class TestCallBasedIntegersStayExact:
113+
"""Tolerance must never apply to integers, in either the scalar or the list case."""
114+
115+
def test_large_int_list_off_by_one_fails(self):
116+
code = "class Solution:\n def f(self, n):\n return [50000000000000001]\n"
117+
results, _metadata = grade_call_based(
118+
code=code,
119+
all_inputs=["[1]"],
120+
all_outputs=["[50000000000000000]"],
121+
fn_name="f",
122+
timeout=10,
123+
)
124+
assert results != [True]
125+
126+
def test_large_scalar_int_off_by_one_fails(self):
127+
code = "class Solution:\n def f(self, n):\n return 50000000000000001\n"
128+
results, _metadata = grade_call_based(
129+
code=code,
130+
all_inputs=["[1]"],
131+
all_outputs=["50000000000000000"],
132+
fn_name="f",
133+
timeout=10,
134+
)
135+
assert results != [True]
136+
137+
def test_small_int_list_off_by_one_fails(self):
138+
code = "class Solution:\n def f(self, n):\n return [1, 2, 4]\n"
139+
results, _metadata = grade_call_based(
140+
code=code, all_inputs=["[1]"], all_outputs=["[1, 2, 3]"], fn_name="f", timeout=10
141+
)
142+
assert results != [True]
143+
144+
def test_values_are_close_gates_on_float_type(self):
145+
assert not values_are_close(50000000000000000, 50000000000000001)
146+
assert not values_are_close(41, 42)
147+
assert values_are_close(50000000000000000, 50000000000000000)
148+
assert values_are_close(0.1, 0.1000000001)
149+
assert not values_are_close(0.1, 0.5)
150+
151+
def test_booleans_never_get_tolerance(self):
152+
assert not values_are_close(True, 0.9999999999)
153+
assert not values_are_close(1.0, False)
154+
assert values_are_close(True, True)
155+
assert values_are_close(1.0, True)

0 commit comments

Comments
 (0)