ccompiler
CCompiler
Description
CCompiler is a long-horizon coding environment that tasks an agent with building a C compiler from scratch, in Rust, with no help from existing compiler backends. Reward is the change in how many .c tests a single build of the agent's compiler handles correctly, relative to its previous build — valid programs must produce assembly that links and runs to the same exit code and stdout as a gcc reference; invalid_* programs must be rejected with a non-zero exit and no output file. finished=True only when one build passes every test in the corpus at once.
Capabilities
- Authoring a substantial Rust crate (lexer → parser → semantic analysis → IR → x86_64 codegen)
- Multi-file refactoring across an evolving codebase
- Reward-guided iteration against a 20-chapter curriculum of C features
- Git / worktree workflow when coordinating parallel subagents
- Open-ended long-horizon execution (thousands of tool calls)
Compute Requirements
Agents are given a sandbox with 8 GB of RAM and 4 CPUs. The network is blocked. Rust toolchain, bubblewrap, the system cc (used by the harness to link emitted asm), and the cc-runner harness binary are preinstalled.
License
ORLv1 for the environment code. The underlying test corpus is sourced from nlsandler/writing-a-c-compiler-tests (MIT) and is filtered + reference-graded at image build time. See citations.
Tasks
A single mega-task: ccompiler-train — pass every .c test in the filtered Sandler corpus. The corpus is baked into the image at build time. There is no train/test split.
Reward Structure
This is a dense, verifiable reward environment.
Reward is the change in how many tests a single build of the agent's
ccompiler binary passes, relative to its previous build:
- +1 for each test the current build passes that the previous build did not.
- −1 for each test the previous build passed that the current build now fails,
so a build that breaks more than it fixes is net negative. - Reject cases are credited per chapter in proportion to that chapter's valid pass
rate:credited_invalid_N = min(invalid_passed_N, ceil(valid_rate_N × invalid_total_N)).
Rejecting a program is trivial (exit 1rejects all 692invalid_*cases), so without
this an always-reject binary would score 55% of the corpus. With it, an always-reject
binary scores 0, a compiler that fully handles chapters 1..k scores every test in those
chapters (the same set Sandler's--chapter krunner tests), and a build that passes
everything is credited everything. - Valid cases expecting exit 0 with no output are credited per chapter in proportion
to that chapter's other valid cases:credited_trivial_N = min(trivial_passed_N, ceil(nontrivial_rate_N × trivial_total_N)). 284 of the 557 valid programs exit 0 and
print nothing, so without this a compiler that emitsreturn 0for every input would
score 284. With it, that binary scores 0, while a compiler that fully handles a
chapter is credited all of it. The reject credit above uses the resulting valid
count. - Each chapter is scaled by a perturbed re-grade. Every build, up to 16 of the
programs it passes in each chapter (valid and reject cases drawn separately, with a
freshos.urandomseed) are graded again afterperturb.pyrenames their
identifiers and re-flows their whitespace and comments. Valid programs from chapter 2
on also get token-changing rewrites that use only constructs of their own chapter
(return (e);, a constant re-expressed with the same type and value such as(3 + 4),
an extra;at the start of a block; see below). The expected result is unchanged,
so a compiler passes a copy exactly when it passes the original, while a table of
answers keyed on the public sources (by hash, path, or the token sequence with
identifiers, whitespace and comments erased) passes only the originals. Per chapter
and kind,
credited = min(passed, ceil(min(1, still_passed/sampled + 1/16) × passed)), and the
reject credit above uses the credited valid rate. The sample comes from the build's
own passes, so a deterministic compiler is never capped (no sampling noise); the
1/16 slack forgives one flaky copy per sample and is all a table keeps. - 0 for a build that fails to compile, and 0 for a grading run that
wall-times-out before the whole corpus has run — neither changes the baseline. finished=Trueis reported only when one build passes every test in the
corpus at once.
Because reward tracks the current binary rather than a historical union of
everything ever passed, total trajectory reward telescopes to the credited pass
count of the build the agent finishes with. Every scored run grades the full corpus,
which is what makes that delta coherent; run_tests(paths=...) only narrows
which failures are listed back to the agent and cannot change the score.
Per-test pass/fail is decided mechanically, with no LLM grader:
valid/*.c— the agent's compiler must emit asm that, oncecc -no-pie-linked and run, matches the reference's exit code and stdout (the reference is gcc run against the same source at image-build time).invalid_*/*.c— the agent's compiler must exit non-zero AND produce no output file. The category name (invalid_lex,invalid_parse,invalid_semantics,invalid_declarations,invalid_types,invalid_struct_tags,invalid_labels) hints at the stage that should reject.
Data
The test corpus is Nora Sandler's writing-a-c-compiler-tests suite (MIT), pinned to a specific commit at image build time. build/filter_corpus.py clones the repo, copies the single-file .c programs into /srv/ccc/train/chapter_*/, and pre-computes a (exit, stdout) reference for every valid/*.c by compiling and running it under gcc 11 on x86_64 (build/gen_expected.Dockerfile; the result matches Sandler's own expected_results.json, checked by build/check_expected.py). Multi-translation-unit tests (Sandler's helper_libs/ / client/ layouts) are excluded. Sources gcc itself refuses to compile are dropped. The (exit, stdout) reference answers live only in the env-server image (/srv/ccc/expected/expected_by_path.jsonl), never in the agent's sandbox — grading is server-side, so the sandbox holds only the read-only .c inputs.
Tools
Beyond the standard 9 CLI tools (bash, read, write, edit, multi_edit, glob, grep, ls, todo_write), the environment provides:
run_tests(paths=None, timeout_s=1800)— builds/workspace/bin/ccompiler, runs the C-compiler harness against the full corpus (always, regardless ofpaths), and returns delta reward + a size-bounded sample of failures (the tool output stays under 32KB); every case's result is written to/tmp/run_tests/last.tsvin the sandbox (previous build:prev.tsv).paths(.cfiles or directory prefixes) only filters which failures are listed back, so a narrow call cannot change the score.timeout_sis clamped to 60–3600s. Calls are serialized per session (concurrent callers share one sandbox).progress()— read current pass count and cumulative reward without rebuilding.
Time Horizon
Open-ended. The agent is expected to take many turns: scaffold a Rust crate, implement features incrementally (start with chapter 1's int main(void) { return <const>; } programs — 24 free tests), use the reward delta from run_tests to decide which feature to invest in next, and use git worktrees if spawning parallel subagents.
Environment Difficulty
This environment is hard. A C compiler with enough coverage to pass Sandler's 18-chapter curriculum is thousands of lines of Rust across lexer, parser, semantic analyzer, IR, register allocator, and x86_64 codegen. The chapter ordering — returns → unary → binary → locals → control flow → functions → pointers → arrays → structs → dynamic memory → optimisations → register allocation — gives a smooth dense-reward gradient. At image-build time we kept 1,249 tests (557 valid + 692 invalid) after dropping sources the system gcc refuses to compile.
Other Environment Requirements
None. CCompiler requires no external API keys.
Anti-cheat
The task is "build a C compiler from scratch in Rust", so the artifact must be genuine
from-scratch Rust — it must not delegate to another compiler (e.g. a Rust binary
that shells out to gcc -S). "From scratch in language Y" is a property of the source and the
build, not of runtime behaviour, so it is enforced primarily at build time (a runtime sandbox
can never prove what a binary was written in — anything can be embedded). Each run_tests build is
BLOCKED (0 reward, baseline unchanged) if any rule is broken:
- Controlled build:
env -i, pinned toolchain 1.85.0, freshCARGO_HOME+ target dir,
--offline,RUSTFLAGS="-F unsafe_code -C target-feature=+crt-static -C strip=none --print link-args".-F unsafe_codeis compiler-enforced (an#[allow]cannot override it) and also bans
asm!/global_asm!/#[no_mangle]/#[link_section];+crt-staticremoves runtime dynamic
loading. - No launching programs: the built binary is
nm-scanned forexecve/fork/posix_spawn/
system/popen/… (linked in only ifstd::processis used) — the direct block forgcc -S.
dlopenis not banned (static glibc always links it for NSS). - std-only:
cargo metadatamust show local path crates only — no external crates,build.rs,
proc-macros or nativelinks; compiler-backend crates (llvm-sys,cranelift*,clang-sys,
inkwell,bindgen,gcc-rs, the build-scriptcccrate,dynasm*,iced-x86,
rustc_codegen_*) and their symbols are banned outright. - No foreign link inputs: the rustc link line is checked — linker is
cc, inputs come only from
the fresh target dir / toolchain rlibs, and-lis limited to the std set (catches#[link],
which is legal in safe Rust). - Toolchain integrity: rustc,
cc/gcc/as/ld,bwrap/setpriv,ld.so.preload,
nsswitch.confand the test corpus under/srv/ccc(the sources the harness compiles; the
answers are keyed by path, so a source rewritten into a trivial program with the same result
would otherwise pass) are fingerprinted (path/type/size/mode/owner/ctime, which is
unforgeable withoutCAP_SYS_TIME; reading never changes a row). Under/srv/ccconly the files
the grader reads (the case sources and manifests) are compared: the harness is handed the explicit
case list, so files added next to them (ana.outfrom compiling a test in place) and directory
ctimes are ignored, while editing, replacing, moving or deleting an input still shows. The case list and harness
SHA-256-checksummed at session setup; any later
change blocks all subsequent grades. The fingerprint listing is compared on the env server, a
mismatch is re-measured 3× before it blocks (gVisor path lookups can fail transiently), and the
block names the changed paths. The same check runs again after the harness has graded, before
anything is credited, so a change made while a build is being graded (e.g. by a concurrent tool
call) is not banked either. A stray-process sweep runs first: root leftovers are killed,
a process of another uid (which root cannot kill) blocks.
Grade-time isolation (defence-in-depth). The network is blocked, and the artifact runs under
non-root bubblewrap (dropped to uid 65534 first — bwrap fails as root under the gVisor sandbox)
with no /usr/bin, so even attempting to exec cc/gcc fails. Isolation is fail-closed: if
bwrap cannot isolate on an untampered image, grading errors rather than direct-spawning the artifact
as root (the path that enabled the original hack). This is a second layer only; the build-time rules
hold regardless.
Baked answer tables. The corpus is public and readable during development, so an
agent can run gcc -S at dev time and embed the results in pure safe Rust, keyed by a
hash of the source or its path. Two layers stop this:
- the perturbed re-grade above (the main defence): such a table fails the renamed,
re-flowed and (for valid programs from chapter 2 on) rewritten copies, so its chapters
are credited at the 1/16 slack; - stopgap artifact checks on every build (blocked, baseline unchanged): the binary
must be at most 64 MiB, and neither the binary nor the asm it emits may contain gcc's
.ident "GCC: ..."directive; the binary must not contain verbatim corpus source text
(the longest distinctive line of each test file).
perturb.py's renaming is an injective map onto fresh names (keywords, main, names
starting with _, names used on preprocessor lines, and every symbol the C runtime and
linker provide are never renamed), and each result is checked to lex to the expected
token sequence. Valid programs from chapter 2 on are also rewritten (restructure), each
rewrite keeping the program's exact behaviour and using only constructs of its chapter:
return e;→return (e);(parentheses arrive in chapter 2);- a constant →
(c),(-(-n))or(~(~n))(chapter 2), or(a + b)/(a - b)with
positiveintoperands (chapter 3). The arithmetic forms are drawn only for a plain
decimalintconstant in 1..INT_MAX, so the result is anintof the same value with
no overflow;0, suffixed, floating and larger constants are only parenthesized, so a
null pointer constant stays a literal0. Constants are left alone outside function
bodies, instatic/externdeclarations, incaselabels and in[N], where the book's
subset wants a literal; - one or two null statements at the start of a block (chapter 5), never before a
switch
body's first label.
Reject cases keep their token sequence, so they stay invalid for the same reason; their
credit is already bounded by the chapter's credited valid rate.
build/check_perturb.pychecks all of it with gcc on the whole corpus: for 10
perturbations of each of the 1,242 perturbable programs, valid programs produce the same
exit status and stdout, and every program gets the same compile status and diagnostics
(the 7 others are lexically brokeninvalid_lexcases, left out of the sample).
Residual risk. Chapter 1 programs are all return N;, which no chapter-1 construct
can re-express, so a table still matches their copies (a chapter-1 compiler is a few
lines anyway). A table keyed on a normalization that undoes the rewrites (folding
constant subexpressions, dropping redundant parentheses and null statements) is not
caught; it takes a C parser, and closing it fully needs held-out programs rather than
perturbed public ones.
This environment teaches compiler-construction skills and contains no inherently dual-use objective.
Citations
@book{Sandler_WritingACompiler,
author = {Sandler, Nora},
title = {Writing a C Compiler},
year = {2024},
publisher = {No Starch Press},
}
@software{Sandler_WACTests,
author = {Sandler, Nora},
title = {writing-a-c-compiler-tests},
url = {https://github.com/nlsandler/writing-a-c-compiler-tests},
note = {MIT license; pinned at image build time.}
}
@dataset{GRCCompiler,
author = {General Reasoning Inc. Team},
title = {CCompiler},
year = {2026},
publisher = {OpenReward},
url = {https://openreward.ai/GeneralReasoning/ccompiler}
}