A Python .pyc decompiler that reads CPython 3.0 – 3.14 bytecode and
recovers the original .py. The pipeline is a deterministic rule pass
(declarations, signatures, decorators, PEP 695 generics, PEP 749 lazy
annotations) followed by one Codex CLI call per module to fill the
bodies and module-level statements the rule pass can't recover from
opcodes alone.
2,794 modules across 13 corpora, measured twice — once with the deterministic rule-only path (no LLM, fully reproducible offline) and once with hybrid-rewrite (rule pass + one Codex gpt-5.5 call per module). Two new tooling members in this repo make the contamination question directly measurable:
pychd-pyfuzz generates random syntactically-valid Python via direct AST construction — every sample is fresh, never published, never seen by any LLM. Lives in pychd_pyfuzz/ and on PyPI as pychd-pyfuzz.pychd-pyobf anonymises a .pyc (renames identifiers, strips strings / docstrings / filenames / line tables) while preserving the opcode stream byte-for-byte. Lives in pychd_pyobf/ and on PyPI as pychd-pyobf.Together they let us run two new families of corpus on top of the existing benchmark suite:
fuzz-synthetic (200 modules) — pyfuzz-generated, guaranteed LLM-naïve. The strongest contamination guarantee in the repo.<corpus>-obf (815 modules across 5 mirrors of stdlib / stdlib-full / pypi / pypi-top20 / humaneval) — same bytecode structure as the raw counterpart, identifiers stripped. The delta between raw and -obf is the contamination signal that lets us put a number on "how much of the headline LLM score is memorisation".Raw vs. anonymised, hybrid-rewrite mode, same backend model:
| Corpus | Raw strict_match |
-obf strict_match |
Δ (memorisation lift) | Raw BS |
-obf BS |
Δ |
|---|---|---|---|---|---|---|
stdlib |
100 % (10/10) | 86.7 % (13/15) | −13.3 pt | 60.0 % | 0.0 % | −60.0 pt |
stdlib-full |
91.5 % (140/153) | 80.4 % (123/153) | −11.1 pt | 84.3 % | 2.6 % | −81.7 pt |
pypi |
89.9 % (170/189) | 82.0 % (155/189) | −7.9 pt | 33.3 % | 3.2 % | −30.1 pt |
pypi-top20 |
84.5 % (576/682) | 83.4 % (569/682) | −1.1 pt | 63.3 % | 5.3 % | −58.0 pt |
humaneval |
100 % (164/164) | 100 % (164/164) | 0 pt (algorithmically simple) | 98.2 % | 86.6 % | −11.6 pt |
_n0, _v0 will not behave like the original "import the module and call its documented function" smoke test. It's an artefact of the metric, not the decompiler, and it shows up cleanly here.The number we'd ask a security-conscious reader to actually trust as "what hybrid-rewrite does on never-before-seen code":
| Metric | fuzz-synthetic (LLM-naïve, 200 modules) |
recent-pypi (release-date proxy, 182 modules) |
|---|---|---|
parses |
100 % | 100 % |
signature_match |
100 % (rule-only) → 100 % (hybrid) | 98.4 % → 99.5 % |
declaration_match |
100 % (rule-only) → 100 % (hybrid) | 98.4 % → 99.5 % |
strict_match |
21.0 % (rule-only) → 86.0 % (hybrid) | 45.6 % (rule-only) → 81.9 % (hybrid) |
BS (behavioural smoke) |
0.0 % (rule-only) → 92.0 % (hybrid) | 14.3 % → 20.3 % |
Hybrid-rewrite reaching 86.0 % strict-AST match on fuzz-synthetic — bytecode that no LLM has ever seen — is the clean answer to "does pychd's hybrid path actually decompile, or does it just remember?": it decompiles. The contamination differential adds ~5–13 pt on contamination-likely corpora; that's the share that is not skill.
| Mode | parses |
signature_match |
declaration_match |
strict_match |
BS |
|---|---|---|---|---|---|
| Rule-only (no LLM, deterministic) | 100 % | 99.7 % | 99.7 % | 43.1 % | 19.3 % |
| Hybrid-rewrite (rule pass + 1 Codex call/module) | 100 % | 99.7 % | 99.7 % | 86.5 % | 49.4 % |
Pass@1 on HumanEval: rule-only 2.4 % → hybrid-rewrite 97.6 %, but every HumanEval prompt is in the backend model's training data, so this is mostly an LLM-solves-HumanEval-from-memory signal rather than a decompilation signal.
The take-away for anyone reading benchmark numbers for an LLM-assisted decompiler: separate the rule-only baseline from the LLM lift, and measure on a corpus the backend model cannot have seen. This repo is the first I know of to ship both halves of that — pychd-pyfuzz + pychd-pyobf are independent PyPI packages so other Python decompiler authors can drop the same harness into their CI. See §LLM contamination disclosure for the worked example (_colorize.py) and §Comparison with prior Python decompilers for the 23-module stdlib + PyPI head-to-head against uncompyle6 / decompyle3 / pycdc / PyLingual.
uv tool install pychd
pychd decompile path/to/module.pyc --hybrid-rewrite --backend codex
# rules-only (deterministic, no LLM, offline, free — best for declaration recovery):
pychd decompile path/to/module.pyc --rules-only
--hybrid-rewrite is the default at the CLI. It uses your existing
codex login session — set model = "gpt-5.5" in
~/.codex/config.toml (or pass -c model=...) to control which
model. No extra API key needed.
If you want a fully offline, deterministic, audit-friendly run with
no LLM calls and no contamination risk, use --rules-only — that is
the path whose numbers the headline table above reports.
The benchmark numbers on this page should be read as upper bounds under likely-contaminated conditions, not as evidence of clean generalisation.
tools/synthetic_corpus/ corpus (11 modules, 625 LoC,
committed 2026-05-26) was drafted with the assistance of an LLM
during this project's development. The exact source text did not
exist on the public internet before that date, but the modules were
produced by the same model family this benchmark uses as a backend,
so it cannot honestly be called LLM-naïve. We keep it in the
benchmark because it exercises specific PEP-695 / PEP-749 / match-
statement constructs, but we no longer claim it isolates
"uncontaminated" performance.requests, click, attrs, flask, httpx,
rich) and the top-20 sweep overlap published training corpora.
Recent wheel pins (e.g. certifi 2026.5.20) reduce exact-version
memorisation risk for those packages, but do not eliminate
pattern-level memorisation.| Metric | Rule-only | Hybrid-rewrite | Trust |
|---|---|---|---|
parses |
100 % | 100 % | ✓ honest — just ast.parse |
signature_match (rule-only) |
99.8 % | — | ✓ honest — bytecode-derived |
declaration_match (rule-only) |
99.6 % | — | ✓ honest — bytecode-derived |
signature_match (hybrid Δ +0.2 pt) |
— | 100 % | ✗ memorisation — see worked example below |
strict_match (rule-only) |
36.0 % | — | ✓ honest — bytecode-derived |
strict_match (hybrid Δ +57 pt) |
— | 93.2 % | ⚠ unmeasured mix of memorisation + canonical-form derivation |
BS (rule-only) |
42.1 % | — | ✓ honest |
BS (hybrid Δ +26 pt) |
— | 68.1 % | ⚠ contamination plausible |
BN (rule-only) |
7.2 % | — | ✓ honest |
BN (hybrid Δ +42 pt) |
— | 48.9 % | ⚠ contamination plausible — body recovery from memory yields exact bytecode |
FC Pass@1 (HumanEval) |
2.4 % | 97.6 % | ✗ HumanEval is published, almost certainly memorised by the backend model. This metric measures "LLM solves HumanEval", not "pychd decompiles" |
| Edit similarity | 0.445 | 0.753 | ⚠ memorisation pushes this towards 1.0 by construction |
Lib/_colorize.pyThe two CPython stdlib modules that fail rule-only signature_match
(_colorize.py, _pylong.py) contain if False: / if 0: guards.
For _colorize.py L8-12:
# types
if False:
from typing import IO, Self, ClassVar
_theme: Theme
CPython's constant folder erases the if False: block entirely.
After compile() the bytecode contains zero IMPORT_NAME typing,
zero STORE_NAME IO, etc. — the only survivor is _theme: Theme
as a PEP 749 lazy annotation in the __annotate__ closure.
Pychd's rule pass correctly leaves those imports out of the
recovered tree (you cannot decompile what isn't there). Hybrid-
rewrite "fixes" the signature_match score by writing
from typing import IO, Self, ClassVar into the output anyway —
necessarily from training-data memorisation of CPython, since the
.pyc carries no information about that line. That is the concrete
mechanism behind the 0.2 pt sig-match gain, and the same kind of
mechanism is plausibly contributing to the much larger strict-
match / BN / Pass@1 gains.
fuzz-synthetic and *-obfThe contamination disclosure used to end with "a truly contamination-free evaluation would need a privately authored, never-published corpus that no LLM has touched at any stage of training. This repository does not ship one." That gap is now closed by two PyPI packages built alongside pychd:
pychd-pyfuzz generates random,
syntactically-valid Python by direct AST construction. Every
sample is fresh on every run, has random identifiers, and has
never been published — so by construction no LLM has memorised
it. We build a 200-module fuzz-synthetic corpus on every
just paper run.pychd-pyobf anonymises a CPython .pyc
(rename identifiers, strip strings / docstrings / line tables /
filenames) while preserving the opcode stream byte-for-byte. We
use this to build a <corpus>-obf mirror of each existing
corpus; pychd is then re-evaluated against the anonymised
reference. The delta between raw and -obf scores on the same
pipeline is, mechanically, the contamination signal — same
bytecode, just with the surface tokens an LLM could have
memorised stripped out.Both packages are independent PyPI distributions (pip install
pychd-pyfuzz pychd-pyobf) so any Python decompiler author can run
the same trust-tier audit against their own tool. The expected
shape of a non-contaminated result is approximately:
strict_match ≈ within a few points of the raw-corpus
number. The rule pass is bytecode-driven and identifier-agnostic,
so anonymisation should not move it materially.strict_match should drop on -obf corpora by an
amount equal to the LLM's contamination advantage on that corpus.
Anything large (e.g. > 30 pt) is strong evidence the upstream
hybrid score is contamination-driven.The measured numbers for this repo's hybrid-rewrite path are in the "contamination differential" table at the top of the README.
pychd routes every .pyc through two passes:
return self.x,
return cls(...), constructor self.x = x, etc.). Output is
reproducible offline and audit-friendly. Bodies it can't recover
remain as pass.$ claude mcp add pychd \
-- python -m otcore.mcp_server <graph>