All use cases
Use case 02 · RL environment builders

Graders that can’t be passed without the work

Before you ship an environment, find out whether its grader pays for answers that did none of the work.

Available now · more at launchPython librarybohrin audit at launch3 min read

When a model is trained with reinforcement learning, it tries a task over and over, and a grader decides whether each attempt passed. The model learns to do whatever earns a pass. If your grader can be passed without doing the task, the model will find that route, and every team that trains on your environment inherits the same shortcut.

How a grader gets fooled

Take a simple task: write add(a, b) that returns the sum. Here is a grader that looks reasonable. It loads the submission and calls the function:

grader.py
1def my_grader(submission):
2 namespace = {}
3 try:
4 exec(submission, namespace)
5 namespace["add"](2, 3)
6 return True
7 except BaseException:
8 return False

It checks that the function runs. It never checks the answer. So a function that returns 0, or does nothing at all, passes. A model trained against this grader learns that doing nothing is enough.

Real graders are more careful than this, but they get fooled in the same basic ways. A submission can quit the program with a “success” code before any check runs. It can return an object that claims to be equal to every expected answer. It can edit the tests that judge it. This behaviour has a name: reward hacking. The model isn’t at fault. The grader has a gap.

219

flaws found across 10 agent benchmarks, most reaching near-perfect scores without solving a task.

Source · BenchJack, 2026

“Robust”

is the minimum, say the people who build RL environments: “Reward hacking is a big issue… It needs to be robust.”

Source · Epoch AI interviews

What Bohrin does

Bohrin sends your grader submissions that are wrong on purpose. A good grader must reject all of them. Every one your grader accepts is a gap, and Bohrin proves it. It never needs your model, an API key or a GPU. It tests the thing that judges the model.

Step 1. Describe a task. A task is an ID, the prompt, and a reference solution you know is correct.

check_grader.py
1from bohrin.ir.task import Task
2
3reference = "def add(a: int, b: int) -> int:\n total = a + b # the sum\n return total\n"
4task = Task(id="add", prompt="Write add(a, b) that returns the sum.", reference=reference)

Step 2. Get the wrong-on-purpose submissions. Bohrin builds them from your reference. Each keeps your function’s name and signature and does no work.

check_grader.py
1from bohrin.mutate.battery import battery
2
3result = battery(task)
4for candidate in result.candidates:
5 print(candidate.provenance.operator)
real output · bohrin 0.4.2
$ python check_grader.py
constant_implementation
drop_side_effect
equality_spoofing
raise_not_implemented
success_exit
  • —constant_implementation returns a constant of the right type: return 0
  • —drop_side_effect has an empty body: pass
  • —equality_spoofing returns an object that says it equals anything
  • —raise_not_implemented raises NotImplementedError
  • —success_exit exits with “success” before any check can run

Step 3. Run your grader on them, and score it. Bohrin never runs your grader. You do, in your own setup. Then Bohrin scores what happened. For the weak grader above:

real output · bohrin 0.4.2
>>> print(coverage_score(attempts))
COVERAGE SCORE: 0 / 100 95% CI 0–79 0 of 1 caught categories: 1 of 5 (battery 2)

Zero out of 100: it paid for an empty function. Now a grader that checks the value and its type, type(r) is int and r == 5, catches all five and scores 100. Checking the type matters. With == 5 alone, the object that claims to equal everything gets through.

Step 4. Check it still accepts correct work. A grader that rejects everything catches every cheat, so Bohrin also sends rewrites of your reference that it can prove do exactly the same thing, such as the same code with the comment removed. A good grader must accept them.

real output · bohrin 0.4.2
>>> print(scorecard([task], attempts, controls))
program: 1 task scored
COVERAGE SCORE: 100 / 100 95% CI 21–100 1 of 1 caught categories: 1 of 5 (battery 2)
CORRECT-WORK ACCEPTANCE: 100 / 100 95% CI 34–100 2 of 2 accepted relations: 2
not run: 12 applicable weakness classes: BGW-104, BGW-105, BGW-106, BGW-107, BGW-119, BGW-121, BGW-122, BGW-123, BGW-125, BGW-127, BGW-128, BGW-137

Step 5. Look up the fix. Every gap points at a named weakness, and every weakness comes with advice on how to close it:

real output · bohrin 0.4.2
>>> print(weakness.id, weakness.name); print(weakness.fix)
BGW-101 Trivial implementation
Check return values against known outputs on inputs the submission cannot see, and include at least one case a constant or empty body cannot pass.

How to read the numbers

  • —Coverage Score: out of 100, the share of cheats your grader caught. Higher is better.
  • —Correct-work acceptance: out of 100, the share of correct rewrites your grader accepted. Always shown beside the Coverage Score, because either number alone can be gamed by trading one for the other.
  • —95% CI is the margin of error. One task is a small sample, so the range is wide (21–100). Add more tasks and it narrows.
  • —not run lists every known kind of gap that applies to your grader and wasn’t tried yet. Not tried never counts as passed.

A clean result is not a guarantee

100 out of 100 means these checks found nothing. It doesn’t mean the grader can’t be fooled. The “not run” line tells you exactly what is still open.

What it doesn’t do yet

  • —Python graders and Python code today, starting with coding environments.
  • —Graders that take code, or run a program on inputs and compare outputs, are fully supported now.
  • —Graders that apply a patch to a repository get template submissions today. Graders that run an agent inside a container are coming at launch.
  • —bohrin audit, which runs the whole check on your environment in one command, is coming at launch. Today you run your grader yourself, as above.

Try it in two minutes

Install
$ pip install bohrin
Then, in Python
>>> from bohrin.mutate.battery import battery
Read the full docs

Start with the one you need. It’s one install.

$ pip install bohrin