Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 127 additions & 36 deletions src/inference_endpoint/evaluation/livecodebench/lcb_serve.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"""

import argparse
import ctypes
import json
import logging
import multiprocessing as mp
Expand All @@ -43,26 +44,46 @@
from collections.abc import Callable
from concurrent.futures import ProcessPoolExecutor, as_completed
from functools import lru_cache
from multiprocessing.sharedctypes import Synchronized
from pathlib import Path
from typing import cast

import numpy as np
import pandas as pd
from tqdm import tqdm

from .generate import generate_dataset
from .run_lcb_tests import run_test

logger = logging.getLogger(__name__)

# Error codes that mean the judge is broken, as opposed to the submitted code
# failing its tests: -5 TestRunnerError, -6 GradingChildDied (child died
# before grading started, e.g. a bad start method). Submission-attributed
# codes stay out: timeouts (-1), sys.exit() (-7 SubmissionExit), submissions
# that kill their own interpreter (-8 SubmissionKilledChild), and -4 (the
# submission's code failed to compile or define the expected function --
# see the outer except in run_lcb_tests.grade_call_based/grade_stdio callers
# -- which is the submission's fault, not the judge's, even though it's
# labeled "Error during testing").
_LCB_INFRA_ERROR_CODES = {-5, -6}
Comment thread
liayan marked this conversation as resolved.

def execute_code_single(test_suite_json: str, code: str, timeout_sec: int = 60):

def execute_code_single(
test_suite_json: str,
code: str,
timeout_sec: int = 60,
*,
started_flag: Synchronized | None = None,
) -> tuple[list, dict]:
# Run code with lcb_runner. Note that the lcb_runner has a very rudimentary sandbox
# which is extremely easy to bypass, and as such it is recommended to run this both
# in an unprivileged container and in a separate process.
import numpy as np

from .run_lcb_tests import run_test

res, metadata = run_test(
{"input_output": test_suite_json}, test=code, timeout=timeout_sec
{"input_output": test_suite_json},
test=code,
timeout=timeout_sec,
started_flag=started_flag,
)

# LCB results are expected to be plain booleans or error codes.
Expand All @@ -78,16 +99,36 @@ def execute_code_single(test_suite_json: str, code: str, timeout_sec: int = 60):


def execute_code_single_suppressed_errors(
*args, resp_buffer: list | None = None, **kwargs
):
test_suite_json: str,
code: str,
timeout_sec: int = 60,
*,
resp_buffer: list | None = None,
started_flag: Synchronized | None = None,
) -> tuple[list, dict]:
"""Wrapper around execute code so that all errors are resurfaced as failed tests"""
try:
res, metadata = execute_code_single(*args, **kwargs)
# started_flag is flipped inside run_test, once judge-side setup
# (reliability_guard, suite parse) is done and the submission's own
# code is next; see run_lcb_tests.run_test.
res, metadata = execute_code_single(
test_suite_json, code, timeout_sec=timeout_sec, started_flag=started_flag
)
if not isinstance(res, list):
raise ValueError(f"Expected boolean result, got {type(res)}")

if not isinstance(metadata, dict):
raise ValueError(f"Expected metadata to be a dict, got {type(metadata)}")
except SystemExit as e:
# sys.exit() in submitted code is a BaseException, not caught below.
# It's the submission's fault, not the judge's, so give it its own
# error code and keep it out of _LCB_INFRA_ERROR_CODES.
res = [-2]
metadata = {
"error": f"Submission called sys.exit({e.code!r})",
"error_code": -7,
"error_message": "SubmissionExit",
}
except Exception:
# Magic number (see https://github.com/LiveCodeBench/LiveCodeBench/blob/28fef95ea8c9f7a547c8329f2cd3d32b92c1fa24/lcb_runner/evaluation/compute_code_generation_metrics.py#L65)
res = [-2] # LCB internal error code for test runner failed test cases
Expand All @@ -106,7 +147,7 @@ def run_code_subprocess(
test_suite_json: str,
code: str,
timeout_sec: int = 60,
):
) -> tuple[list, dict]:
# Compute global timeout -
# https://github.com/LiveCodeBench/LiveCodeBench/blob/28fef95ea8c9f7a547c8329f2cd3d32b92c1fa24/lcb_runner/evaluation/compute_code_generation_metrics.py#L43

Expand All @@ -117,37 +158,64 @@ def run_code_subprocess(
suite["inputs"]
) + flat_timeout_extension

manager = mp.Manager()
resp_buffer = manager.list()
p = mp.Process(
target=execute_code_single_suppressed_errors,
args=(
test_suite_json,
code,
),
kwargs={
"resp_buffer": resp_buffer,
"timeout_sec": timeout_sec,
},
)
p.start()
p.join(timeout=global_timeout)
with mp.Manager() as manager:
resp_buffer = manager.list()
# typeshed types ctx.Value() as SynchronizedBase, which lacks .value
started_flag = cast(Synchronized, mp.Value(ctypes.c_bool, False))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low (concurrency): mp.Value(ctypes.c_bool, False) defaults to lock=True, allocating a semaphore per sample and making every .value access acquire it. The parent reads started_flag.value (line 188) after p.kill(); POSIX semaphores aren't robust, so a child SIGKILLed while holding this lock would block the parent's read forever. Unreachable today (the child holds the lock only for the microsecond write at run_lcb_tests.py:515, long before the timeout kill), but the flag is single-writer / single-reader-after-join, so lock=False removes the deadlock surface and a per-sample semaphore allocation for free.

p = mp.Process(
target=execute_code_single_suppressed_errors,
args=(
test_suite_json,
code,
),
kwargs={
"resp_buffer": resp_buffer,
"timeout_sec": timeout_sec,
"started_flag": started_flag,
},
)
p.start()
p.join(timeout=global_timeout)

if p.is_alive():
p.kill()
timed_out = p.is_alive()
if timed_out:
p.kill()
p.join()

if len(resp_buffer) == 0:
# Assume timeout
res = [-1] * len(suite["inputs"])
if len(resp_buffer) > 0:
return resp_buffer[0]

started = bool(started_flag.value)
exitcode = p.exitcode

# No result was reported: every test case counts as failed, only the
# attribution differs.
res = [-1] * len(suite["inputs"])
if timed_out:
# Still running at the deadline: the submitted code took too long.
metadata = {
"error": "Test suite timeout",
"error_code": -1,
"error_message": f"Subprocess did not complete in time ({global_timeout}s)",
}
return res, metadata
elif started:
# The interpreter died while grading was running (os._exit(),
# segfault, OOM, ...). Grading executes the untrusted submission, so
# this is the submission's fault, not the judge's.
metadata = {
"error": "Grading child killed while executing the submission",
"error_code": -8,
"error_message": f"SubmissionKilledChild (exitcode={exitcode})",
Comment thread
liayan marked this conversation as resolved.
}
else:
res, metadata = resp_buffer[0]
return res, metadata
# Died before grading started (e.g. bad start method): the judge is
# broken.
metadata = {
"error": "Grading subprocess died before grading started",
"error_code": -6,
"error_message": f"GradingChildDied (exitcode={exitcode})",
}
return res, metadata


class LCBTestLoader:
Expand Down Expand Up @@ -270,6 +338,7 @@ def __call__(
for qid, test_codes in zip(question_ids, codes, strict=False):
results[qid] = [False] * len(test_codes)
futures = {}
infra_errors = 0

with ProcessPoolExecutor(max_workers=self.n_lcb_workers) as executor:
for qid, test_codes in zip(question_ids, codes, strict=False):
Expand All @@ -290,9 +359,19 @@ def __call__(
qid, code_idx = futures[future]
res, metadata = future.result()
if "error" in metadata:
logger.warning(
f"Test execution error for question {qid}: {metadata}"
)
if metadata.get("error_code") in _LCB_INFRA_ERROR_CODES:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium (testing): Two coverage gaps around the infra taxonomy this line keys on. (1) -5 TestRunnerError (the in-process judge-crash path from execute_code_single_suppressed_errors's except Exception, line 137) is never exercised — every infra test forces -6 via os._exit before that try runs, so -5, its traceback payload, and its participation here and in the guard are untested. (2) The guard's infra_errors == len(futures) boundary is only tested at its all-or-nothing extremes; a mixed batch (some -5/-6, some pass/fail) — which must not fire the guard yet still ERROR-log the infra sample — is uncovered, so a regression to >= or an infra_errors miscount would pass the entire suite.

infra_errors += 1
logger.error(
f"Test execution error for question {qid}: {metadata}"
)
else:
# Routine submission-attributed outcomes (timeout,
# sys.exit, os._exit, bad code) -- expected at scale,
# would otherwise flood ERROR and drown out the
# infra signal above.
logger.warning(
f"Test execution error for question {qid}: {metadata}"
)

# LCB uses any result > 0 as a 'pass' since:
# Negative numbers indicate error codes
Expand All @@ -317,6 +396,17 @@ def __call__(
exc_info=True,
)

# Every subprocess hitting an infra error means the judge itself is
# broken, not that every code sample failed its tests. Timeouts are
# excluded: a batch where every submission loops forever is a valid
# 0 score, not a broken judge.
if futures and infra_errors == len(futures):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium (error-handling / accuracy): The all-infra guard is narrower than its intent:

  • It only fires when every future is -5/-6. A single legitimate non-infra result among N (a real -2/-7/timeout) drops infra_errors below len(futures), so a judge broken for a subset of samples is silently folded into the score.
  • -8 (a post-started_flag crash — see the line-117 thread) and -1 (timeout) are excluded, so a wholesale judge crash during setup escapes entirely.
  • The classification gate keys on "error" in metadata (line 368), but LCB's own outer handler returns [-4] with only error_code/error_message and no "error" key (run_lcb_tests.py:521, 541). A systemic exception inside grade_* → every sample -4 → not logged, not counted infra → silent pass@1 = 0.

Consider deriving the infra set structurally (an IntEnum) and treating "no result / judge-side death / grade-time exception" as infra regardless of the "error" key.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the missing error key so -4 at least logs (225598a), but kept it out of _LCB_INFRA_ERROR_CODES on purpose — that outer catch is reached almost entirely by submissions that fail to compile, which is squarely the submission's fault, not the judge's; flagging it infra would abort ordinary "model wrote broken code" batches. The all-or-nothing threshold and -8/-1 exclusion are real gaps but a false-positive/negative tradeoff on the guard, so I'd rather get your take before picking a number than just change it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IntEnum's the same ask as before with @nv-alicheng

Oop - this is an artifact of a direct copy/paste port from the official LCB runner repo. I can take the task to clean it up to fit code standards.

raise RuntimeError(
f"All {len(futures)} grading subprocesses reported "
"infrastructure errors - the LCB judge is broken; refusing "
"to report a 0 score. See the logged error metadata above."
)

return results


Expand Down Expand Up @@ -348,6 +438,7 @@ def __init__(
if n_workers is None:
n_workers = mp.cpu_count() // 2
logger.info("Using %d workers for LCB eval", n_workers)
logger.info("Multiprocessing start method: %s", mp.get_start_method())
self.n_workers = n_workers

self.path_to_dataset = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from decimal import Decimal
from enum import Enum
from io import StringIO
from multiprocessing.sharedctypes import Synchronized

# from pyext import RuntimeModule
from types import ModuleType
Expand Down Expand Up @@ -475,7 +476,7 @@ def grade_stdio(
return all_results, {"execution time": total_execution_time}


def run_test(sample, test=None, timeout=6):
def run_test(sample, test=None, timeout=6, started_flag: Synchronized | None = None):
"""
if test(generated_code) is not None it'll try to run the code.
otherwise it'll just return an input and output pair.
Expand Down Expand Up @@ -506,7 +507,16 @@ def run_test(sample, test=None, timeout=6):
elif test is not None:
results = []

if started_flag is not None:
# Judge-side setup (reliability_guard, suite parse) is done; the
# next thing that runs is the submission itself (grade_call_based
# / grade_stdio compile and exec `test`), so a death from here on
# is the submission's doing, not the judge's.
started_flag.value = True

if which_type == CODE_TYPE.call_based:
# method_name is only None for CODE_TYPE.standard_input (above).
assert method_name is not None
signal.alarm(timeout)
try:
results, metadata = grade_call_based(
Expand All @@ -518,7 +528,11 @@ def run_test(sample, test=None, timeout=6):
)
return results, metadata
except Exception as e:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium (data-integrity): This -4 path attributes judge/dataset-side failures to the submission. grade_call_based parses the ground-truth outputs with all_outputs = [json.loads(o) for o in all_outputs] at run_lcb_tests.py:298, outside any try — so if the dataset's expected-output JSON is malformed it raises here and is labelled -4 ("submission's fault"). But a malformed suite (sample["input_output"]) fails earlier at line 491 and propagates to the wrapper's except Exception-5 (infra, which trips the all-infra guard). The same "bad dataset" fault is thus split across a guard-tripping code (-5) and a non-tripping one (-4), so a systematically malformed dataset silently reports a 0 without the guard firing — the inverse of this PR's judge-vs-submission goal. Consider parsing ground-truth data before the submission runs and attributing its failures to infra.

# Reached only if the submission's code fails to compile or
# doesn't define the expected function -- grade_call_based's
# own per-test-case loop already handles runtime errors.
return [-4], {
"error": repr(e),
"error_code": -4,
"error_message": f"Error during testing: {e}",
}
Expand All @@ -538,7 +552,10 @@ def run_test(sample, test=None, timeout=6):
)
return results, metadata
except Exception as e:
# Same as the call_based branch above: a compile/definition
# failure in the submission's own code, not a judge bug.
return [-4], {
"error": repr(e),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low (testing): This stdio -4 block (and the whole grade_stdio branch) is never executed by the new tests — every suite constant is CALL_BASED (fn_name set), so which_type is always call_based. The "error": repr(e) key added here is therefore untested. A single stdio suite (drop fn_name, e.g. an echo program) would cover the untested half cheaply.

"error_code": -4,
"error_message": f"Error during testing: {e}",
}
Expand Down
Loading
Loading