Benchmark protocol¶
How obelize's migrations and scan are measured. The numbers are in BENCHMARK_RESULTS.md; this file carries none. The harness is argued in ADR-042 and ADR-043. Gate 1 and the rounds were run for the Gemini pack only; the PyPDF2 and openai packs have no Gate 1 measurement or round (ADR-051, ADR-053), and all three have a dry-run measurement.
The target is at least 20 real migration cases with at least 70% autonomous
verified_success (Gate 4): a
target, not a scientific threshold.
Case definition¶
A case is a repository at a pinned SHA, a provider change and ground truth
written by hand before its scan record is opened, in bench/cases.yaml, read in
bench/CASES.md and decided field by field in
ADR-041.
| Field | Description |
|---|---|
id |
Case identifier, stable across rounds. |
provider |
Provider identity, for example gemini. |
change |
The migration being tested. |
from_version / to_version |
Mandatory exact (==) SDK pins, frozen for the round (why). |
repo |
Repository URL. |
sha |
Full 40-character commit SHA. |
license |
SPDX identifier, or none. |
split |
dev or holdout. |
pattern_key |
The sorted set of legacy symbols the case uses; the de-duplication key. |
python |
A full uv interpreter key, for example cpython-3.12.9-macos-aarch64-none, never 3.10. |
install |
Reviewed commands from the repository's own manifest, only to make test_cmd runnable; none without one. |
test_cmd |
A command that exercises the changed code with no credentials and no network, or null with the reason in the ground truth. |
tests_require_network |
Whether the tests need network access. |
ground_truth.call_sites |
Every legacy call site, rendering to file:line:symbol:auto|manual, with kind, and a why on every manual row. auto means a correct edit follows from the code alone, not that the scanner can make it. |
ground_truth.manifests |
The dependency pins the migration must move, in the same form; a pin is not a symbol and does not enter pattern_key. |
ground_truth.must_not_report |
Positions where nothing may be reported, each with a reason (a mock target naming the project's own function, a confusable distribution, a call inside a string). |
ground_truth.files_read |
Every file the reviewer opened. |
ground_truth.tests_cover_change |
The reviewer's judgement on whether the tests exercise the changed code. |
reviewer |
Who prepared the ground truth. |
reviewed_at |
When. |
notes |
Anything a later reader needs to interpret the case. |
Per-case result¶
One JSON file per case at bench/results/<round>/<id>.json, with no absolute
path in it.
| Field | Description |
|---|---|
detected |
Call sites obelize found. |
false_positive |
Findings that are not real usage. |
missed_call_site |
Ground-truth call sites obelize did not find. |
forbidden_reported |
Findings of any kind at a ground_truth.must_not_report position. An actionable one is also a false_positive, but a text_mention there is nothing else. Recorded, not a tier input; round 1's records lack it, and bench/CASES.md counts the same positions in the scan records. |
patch_applied |
Whether a patch was written. |
patch_compiles |
Whether every .py file the run wrote compiles under the case's interpreter, or null when nothing was written. The harness checks it because obelize fix with no verification command does not. |
unmigrated |
Ground-truth rows, call sites and manifest pins, the run left as they were. A row is migrated when the run refused nothing at its (path, line, symbol) and an applied edit landed on its line; one requirements.txt line can hold an applied google-genai add beside a withheld legacy pin. |
false_positive_edit |
Applied edits at a position no ground-truth row names; on a negative control, every applied edit. |
tests_baseline |
{status, passed, failed, skipped} before the migration, run by the harness. status is pass, fail, inconclusive or not_run, the words obelize verify uses. A pytest exit code 1 is fail and 2-5 are inconclusive, except a 2 whose junit report counts an error, which is fail. The counts come from a junit report added to a pytest command (pytest, py.test or python -m pytest, as obelize verify reads one) and are null, never 0, without one. |
tests_after |
The same, after the migration and the to SDK install, with the legacy SDK still installed (step 7). |
human_edits |
Hunks a human had to write after obelize; 0 means autonomous. null until a reviewer reads the patch, and never read as 0. |
patch_correct |
The reviewer's judgement; null until made. |
pr_opened |
null: the harness opens no pull request. |
pr_merged |
Whether it was merged, plus merged_without_changes; null for the same reason. |
runtime_seconds |
Wall-clock time of the obelize run. |
llm_tokens_in / llm_tokens_out |
Token counts, if a model was used. |
llm_cost_usd |
Cost, if a model was used. |
Provenance: obelize_version, obelize_commit and obelize_tree_dirty (read once when the round
starts), pack, pack_sha256,
spec_sha256 (the ScanSpec a scan reads),
python, python_executable (harness paths shown as <case>, <logs>,
<work>, <repo> or <harness-python>), python_build (its sys.version,
which tells two machines' resolutions of one key apart),
machine, os, sdk_from and sdk_to (versions observed in the venv), uv_version,
exclude_newer (the cutoff every install resolved as of), packages_sha256 (the installed set
after step 7, with the checkout's own path elided) and run_at. Round 1's records lack
obelize_tree_dirty, uv_version, exclude_newer and packages_sha256.
Version pinning is mandatory¶
An unpinned install resolves silently to a different SDK: on CPython 3.9.6,
uv pip install google-generativeai google-genai exits 0 with
google-genai==1.47.0, not the API the pack targets. A bare --python 3.10
has resolved to two different interpreters on two runs. So:
- After steps 4 and 7 of the run, the runner asks the case's own interpreter
(
importlib.metadata, notuv pip show) which version it imports. A mismatch, or no answer, errors the case withversion_mismatch. pythonis a full uv interpreter key, and a reviewed command starting withpythonruns the case's interpreter, notPATH's.
Outcome tiers¶
Tiers are computed from the fields above, never assigned. A reviewer's answer
is a field of the record, and a null is never read as a zero or a true. Tiers are tried in this order:
| Tier | Definition |
|---|---|
error |
Crash or timeout at any step: the case measured nothing. |
wrong |
patch_correct is false, patch_compiles is false, false_positive_edit > 0, or a patch turned a passing baseline into a failing after-run. A tool that broke a repository has not partially succeeded. |
unsupported |
Nothing was written: the fail-closed refusal. Whether it was informed is the missed_call_site column. |
partial |
unmigrated > 0, with no wrong edit. |
verified_success |
Everything the key names migrated; false_positive, missed_call_site and human_edits all 0; baseline and after both pass; tests_cover_change true; and, when the after-run has a junit count, at least one test passed, since a suite can exit 0 having skipped everything. |
patched_unverified |
A complete migration nothing verified: tests not_run, inconclusive or not covering the change, a failing baseline, or human_edits unanswered. |
- Only
verified_successcounts toward the target. A case with no test command cannot reach it and stays a case.pr_mergedis a separate, stronger metric, reported on its own. - The ceiling is published before the round.
bench/run.pyceilingcaps a case from its key alone: no test command, tests that do not cover the change, a row the key marksmanual, or a row the pack flags rather than edits.bench/summarize.pypublishes it. - A case with
llm_tokens_in > 0is a separate arm, never in the headline. The headline measures the deterministic rules; a model's edit that passes a repository's tests is otherwise indistinguishable from a rule's. The arms are reported side by side, each with its ownn.
Dev / holdout split¶
- Whole owners go to one side. Per stratum (repositories with a Python test
file, and without), repositories are sorted by
sha256(full_name)and their owners taken in that order into the holdout until it holds at least a third of the stratum's cases (bench/cases.pysplit). Stratifying keeps cases that can reachverified_successon both sides; nobody chooses a side. - The holdout is frozen at the start of a round. A case whose holdout failure
led to a rule change moves to
dev, and the round number increments. - Round 2 starts with
kvcops__Virtual-Herbal-GardenandLJX2017__A-Song-of-Ice-and-Fireindev: they were holdout cases in round 1, and the rule that closed COVERAGE gap 29 was written from their code.bench/cases.yamlstill readsround: 1andholdoutfor them, becausebench/results/round-1/and the results page are round 1's andpython -m bench.summarize --checkholds the two together. The file changes in the commit that publishes round 2. Afixdry run over the 23 round-1 repositories at their pinned commits gives the rows 0.3.0 gave, except the 26 that the rule moved fromneeds_reviewtoauto: 20 in kvcops and 6 inMomen2410__Question-Answering-ChatBot-Using-Gemini1.5.
Anti-inflation rules¶
- No forks.
- At most 2 cases per repository owner.
- The headline rate is reported over cases and, beside it, over unique
pattern_keyvalues (a pattern succeeds when any of its cases does), so forty near-identical call sites are one pattern. - Every headline figure carries
nand the split it was computed over.
Candidate selection already applies these.
Negative controls¶
Cases that must produce no migration: a repository already on google.genai,
one on vertexai.generative_models, one on
vertexai.preview.generative_models, and one that mentions the SDK only in
documentation. Each is confirmed by a scan reporting no call site and no pin,
never by the search that found it, and is excluded from the success rate. The
drawn controls are in bench/CASES.md.
Known false-positive traps, also CI fixtures: the genai alias both SDKs use
(resolution follows the import origin, never the alias); the live
GenerativeModel in vertexai.generative_models and in
vertexai.preview.generative_models, which only the import origin tells from
the legacy one; google.ai.generativelanguage; a repository's own genai
module; and a manifest that disagrees with the code (the code is the evidence).
Reproducible run¶
python -m bench.run runs locally, from the repository root; CI runs only its --fixtures-only pass. It builds POSIX virtual
environments and is not run on Windows. It runs each repository's own install commands and test suite
with no sandbox and the network available, so run it only on a machine or VM that holds no
credentials. A round starts only on a committed tree (bench/results/ aside) whose src/ is where
obelize is imported from, not an installed copy, even one in the checkout's own .venv, so each
record names the code that ran. Every install resolves as of the
round's start (UV_EXCLUDE_NEWER, recorded as exclude_newer), and --exclude-newer <UTC time>
runs a round as of an earlier one. Per case:
- Shallow-fetch the repository at the pinned SHA into
<work>/<round>/<id>/, under the system temporary directory. Apyproject.toml,pytest.ini,.pytest.ini,tox.ini,setup.cfg,setup.pyorconftest.pyin any directory above it errors the case withwork_root_not_isolated: pytest searches upward and would run the suite under that project. uv venv --python <py>with the case's full uv interpreter key. Record the resolved interpreter path, itssys.versionandplatform.machine().- Run the reviewed
installcommands. - Install
google-generativeai==<from_version>and assert the installed version equals it. - Run
test_cmdas the baseline, with a timeout and an output cap. obelize fix --pack gemini/... --apply --non-interactive --model none --json, then compile every.pyfile it wrote with the case's interpreter.- Install
google-genai==<to_version>and assert the installed version equals it. The legacy distribution from step 4 stays installed beside it, so a file the migration missed still imports and the after-run can pass over it.uv pip freezethen lists what is installed: the list is that step's log, and its sha256 ispackages_sha256. - Run the tests again.
- Compare the findings in
findings.jsonagainst the ground truth, and the applied edits inplan.jsonandrun.jsonagainst the rows it names. - Write the result JSON.
Step 6 passes no --verify: the to SDK arrives only at step 7, so obelize's
own after-phase would fail every case; steps 5 and 8 replace it.
The harness grades from the run folder, never from the
exit code. Each step runs without a shell, in a session of its own whose
process group is killed when the step ends, so a process a suite leaves in that group does not
outlive its step, and its output goes to a file, so no child holds a finished step to its ceiling.
A child that starts a session of its own is not reached (TM-10 says the same of
obelize verify). A step inherits PATH, LANG, LC_ALL, TMPDIR, UV_CACHE_DIR,
UV_PYTHON_INSTALL_DIR and PIP_CACHE_DIR where the harness has them, and a HOME of its own
under <work>/home, so no credential, key or tool configuration of the machine reaches an install
or a suite. Without the three directory variables, uv and pip keep their caches and interpreters
under that HOME, and the first round on a machine downloads them again. Step output goes to <work>/<round>/<id>.logs/
and is never committed. The gitignored bench/work/ holds only
checkouts of bench/gate1_collect.py, bench/candidates_collect.py and
bench/cases_collect.py, which run no test suite.
--fixtures-only runs steps 5, 6 and 8 over the four repositories in
bench/fixtures/ against their committed expected results, writing nothing to
bench/results/. Two carry doubles of both SDKs under _vendor/, which
obelize skips, so verified_success is reachable offline. Its executor refuses
every program but the running interpreter, so it cannot reach the network. wrong and error are covered by the tier function's own tests.
python -m bench.summarize writes the round block of
BENCHMARK_RESULTS.md, which lists every generated block
and its test; --check runs in the bench-smoke workflow. The candidate
corpus (URLs, SHAs, licences and metadata only) is
bench/CANDIDATES.md.
Licensing and publication rules¶
- Repository code is never vendored. What is stored is the URL, the SHA, the licence, ground-truth line references and the metrics.
patch.diffis kept only for repositories under MIT, BSD, Apache or ISC; for copyleft or unlicensed repositories, only the metrics.- Every case in every round is published, including
wrong,errorandunsupported. - A case is never deleted. It is marked
retiredwith a reason.
Comparison arm¶
Once, on the dev split, a general-purpose coding agent given the official migration guide runs blind against the same ground truth, and its runtime, human corrections and accuracy are reported beside obelize's. The claim under test: obelize adds an evidence chain, reproducibility and a path that needs no model, not raw accuracy. The result is published either way; ADR-045 argues each part.
- The arm. One agent with filesystem and shell tools, one run per case,
given the repository at the pinned commit, a guide snapshot and
one fixed prompt whose only per-case content
is three paths;
bench/comparison_collect.pyrefuses a case whose prompt differs. Blind means no context from this project and a working directory named by a digest of the case id; the agent is not sandboxed from this repository. - Same restrictions. One pass over the source: neither arm installs
anything or runs the tests. No network beyond the guide, since live search
would make the arm unreproducible. Every free choice favours the agent. Afterwards
python -m bench.comparison_collect recordinstalls each case's requirements and runs its suite over the agent's tree, as step 8 does over obelize's: with a step's environment (above) and as of the round'sexclude_newer, which the comparison record repeats (round 1 named none). - One instrument. A key row is cleared when a scan of the tree afterwards
no longer reports it, matched on
(path, kind, symbol)because edits move lines. A row the scan misses on the untouched tree is credited to neither arm but stays in both arms' corrections. Obelize's cleared count must equal what its plan and withheld list say, case by case; that agreement licenses the scan for an arm with no plan. - Accuracy is key rows cleared over key rows, plus any legacy usage an arm
added. Human corrections are key rows still legacy plus edits the review
rejected: rows, not
human_edits' hunks, because grouping rows into hunks needs an arbitrary gap. Runtime is the wall clock of one pass: obelize'sruntime_seconds, the agent's two stamps it writes; neither includes fetching, venvs or suites. - Review. Whether what an arm wrote is right is recorded per arm per case in
bench/comparison.yaml, andpatch_correctderives from it; the round'sbench/cases.yamlstays frozen. - Repeatability. Three cases run a second time from the same bytes in a fresh tree, compared on files touched, line counts and rows cleared.
- The ceiling.
no test commandcaps any arm; amanualrow caps only a rule, so an arm exercising judgement can still reachverified_success. The results show the ceiling besideverified_success.
Scan precision and recall¶
Gate 1 (PRODUCT.md) asks only whether obelize scan finds what is
there. None of its clauses is computable as written, so each is replaced by a
definition below.
What is measured, and over what¶
| Quantity | Over | Why that population |
|---|---|---|
| Precision and recall | Five cloned repositories, hand-labelled before the scanner ran on them | Gate 1's number; five keys fit a budget where each is written by reading the files. |
| Repositories with at least one zero-bail file | The whole sample: at least sixty repositories from GitHub code search | Decides which rules are worth writing. |
| The unresolved share | Every usage in the whole sample | Gate 1's fallback trigger, a property of real code. |
| What each repository declares about its Python | The whole sample | How often ADR-023 D6's manifest floor fires outside a fixture, read through the shipped scan/runtime.py. |
A row, and what counts as a match¶
A finding matches a labelled call site when path, line, kind and symbol are
all equal, symbols fully qualified
(google.generativeai.GenerativeModel.generate_content); rows are multisets.
The right line with the wrong symbol is one false positive and one miss,
because obelize scan prints the symbol. Precision is TP / (TP + FP) and recall TP / (TP + FN); with no findings,
precision is published as --, never 100%.
Which findings the rate is computed over¶
Only import, call, method_call and attribute. text_mention,
manifest, dynamic, star_import and parse_error are deliberate, not call
sites, and tabled separately. The byte prefilter's file-level precision is a
parsing cost, not a false-positive rate. Each key also lists expected_other
(non-actionable rows it is right to report) and must_not_report, both written
before the scan, so "unexpected" is a pre-registered count.
A zero-bail file¶
A file with at least one actionable scan_status: eligible finding and none a
scan-time rule withheld (needs_review or unsupported); a repository counts
when it has one. Both halves follow from
ADR-010 F-1: one bail withholds
the whole file, and no rule acts on a text_mention.
"Cannot be resolved statically", as a set¶
Gate 1's fallback trigger is computed over these confidence_reason values
only:
| Values | |
|---|---|
| Resolved | direct_import_resolved, alias_resolved, from_import_resolved, receiver_bound_same_scope, receiver_bound_module_const, receiver_bound_self_attr |
| Unresolved | conditional_binding, module_alias_rebound, receiver_unresolved, dynamic_access, star_import, star_import_candidate |
string_or_comment_mention, mock_patch_target, manifest_dependency and
parse_error are not a usage and in neither set; a test asserts the three
sets are exactly the vocabulary.
The sample, and what is wrong with it¶
The frame is GitHub code search for the prefilter token generativeai in
Python, in four file-size bands allocated by population, sampled systematically
across the first four pages of each band. Forks, second repositories of an
owner, the three development-corpus owners and anything over 150 MB are dropped
with a reason; bench/gate1/sample.yaml holds the drawn list, the band
populations and every rejection. Its biases:
- Not random: code search is relevance-ordered and indexes only default branches; size bands remove only the largest visible bias.
- Not reproducible: GitHub re-indexes continuously, so the drawn list is committed, not the query.
- Small single-purpose applications, not codebases that would look for a migration tool.
- Five keys chosen for labelling cost (
python -m bench.gate1_collect choose): the first repository per band with two to eight Python files containing the token, plus one from the most populous band, so none of the largest.
Fix dry run¶
What the rules alone would write, for each bundled pack, over a frame of repositories that use
its library (ADR-024 D10). python -m bench.gate1_collect dryrun --pack <id>
fetches each repository at its pinned commit and runs obelize fix --pack <id> --model none once,
and keeps the number of files the plan would write, the pack's blocked reason, and every withheld
row with its symbol and bail. It always runs the whole frame, so a retry cannot replace the other
records. bench/dryrun_score.py tables the outcomes:
| Outcome | Meaning |
|---|---|
blocked |
the pack stops before it plans, for the blocked reason (the legacy distribution declared nowhere, or with no lower bound or one under the pack's floor, for one; a pack that names its newest release takes an unpinned declaration as that release, and one that names what only its old range defines takes it as that range beside code that uses one; each stops only where the repository shows another release) |
none |
the pack runs and finds no usage; a mention in a comment and a manifest row are none |
held |
usages found, no file written |
partial |
a file written and a row withheld |
complete |
a file written, nothing withheld |
The frame is drawn like Gate 1's, in the same four size bands, with a term per pack: generativeai
for Gemini, PyPDF2 for PyPDF2, and "openai.ChatCompletion" for openai. The openai term is not the
pack's prefilter token: openai matches every modern file too, and the frame would be mostly
repositories the pack has nothing to say about. A repository whose only legacy call is
openai.Embedding is therefore not in its frame. The biases are the sample's
(above), and there is no answer key, so a complete repository
means the plan wrote something, not that what it wrote is right; the rounds measure that, for Gemini.
The Gemini frame holds some of the round-1 cases its rules were written against, so its row is a
comparator and not a holdout.
Error analysis¶
After each round, every key row the round did not migrate is read and becomes a
rule change, a scan change or a recorded limitation, and nothing
else. tests/unit/test_bench_errors.py fails on a cause without a disposition
and on a disposition without a cause.
ADR-044 argues
each part.
| File | Written by | Holds |
|---|---|---|
bench/cases.yaml |
a reviewer, before the round | the rows a complete migration has to change |
bench/results/errors/round-<n>.json |
bench/errors_collect.py, from the run folders |
what the round did with each row, and every finding it reported |
bench/errors.yaml |
a reviewer, after reading the rows | one disposition per cause, and a cause for each row the scan never reported |
The run folders sit in temporary checkouts the next round overwrites, so the collector commits the rows once. A row the scan never reported has no bail code, so its cause is hand-written; the test asserts that list is exactly the round's misses.
- Recorded where. A
limitationnames the ADR that decided it; arule_changeorscan_changenames an open, numbered gap intests/fixtures/scan/COVERAGE.md. The test checks that the ADR exists and the gap is open. - The lever. A bail's count does not say what fixing it buys, because ADR-010 F-1 withholds a whole file on any bail and F-2 withholds the legacy pin until the repository has migrated. The lever is the round recomputed with one cause fixed. With nothing lifted it reproduces the round's migrated count; with everything lifted it falls short by exactly the rows no pass reported. Levers do not add up, and each is an upper bound: a rule refuses at its first failed check, so a refusal behind the recorded one is not in the record, and fixing the recorded cause can release fewer rows than the lever says.
- A wrong key is corrected in
bench/cases.yamlwith the reason in the row'swhy; the round is republished only if a published number moves.