Log in
scripts/import_gitgoodbench.py 443 lines · 15.9 KB · blame Source
88e86b3faccd Rebuild benchmarks as a clean single-root reposi 2 months ago
1
#!/usr/bin/env python3
2
"""Import GitGoodBench dataset exports (CSV or JSONL) into Oak benchmark scenarios.
3
4
Dataset: GitGoodBench -- Tobias Lindenbauer, Egor Bogomolov, and Yaroslav
5
Zharov, "GitGoodBench: A Novel Benchmark For Evaluating Agentic Performance
6
On Git" (REALM 2025, doi:10.18653/v1/2025.realm-1.19). Apache-2.0 licensed,
7
distributed as HuggingFace datasets (JetBrains/git_good_bench,
8
JetBrains/git_good_bench-lite, JetBrains/git_good_bench-train). The authors'
9
released harness is NOT runnable (proprietary code was removed before
10
publication), so imported scenarios execute in OUR agent lane instead.
11
Clean-room rule: no prompts or tool code are copied from the upstream
12
repository; only published dataset rows are consumed.
13
14
Export locally with the HuggingFace `datasets` package (docstring guidance
15
only -- this importer is stdlib-only, never touches the network, and consumes
16
LOCAL files exclusively):
17
18
    datasets.load_dataset("JetBrains/git_good_bench-lite")["train"].to_csv("lite.csv")
19
20
``--csv`` accepts the resulting ``.csv`` or a ``.jsonl`` export (sniffed by
21
file extension).
22
23
Real schema columns: id, name (owner/repository), default_branch, license,
24
stargazers, created_at, topics (semicolon-delimited), programming_language,
25
scenario (embedded structured per-scenario data), sample_type, project_size,
26
difficulty. Raw HuggingFace CSV rows currently encode ``scenario`` as a
27
Python-literal dict string; JSONL exports may carry JSON strings or already
28
structured values. ``sample_type`` takes exactly two values:
29
30
    merge              a historical merge with one or more conflicts
31
    file_commit_chain  two commits bounding a chain where one file changed in
32
                       every intermediate commit
33
34
Canonical mapping (used whenever a row carries ``sample_type``):
35
36
    sample_type "merge"             -> scenario_type "merge_conflict_resolution"
37
    sample_type "file_commit_chain" -> EXPANDED into TWO emitted scenarios:
38
        <id>--rebase   scenario_type "interactive_rebase"
39
        <id>--commits  scenario_type "iterative_commit"
40
41
Repo comes from "name", scenario id from "id" (row index fallback);
42
difficulty / programming_language / default_branch are carried through onto
43
every emitted scenario under "metadata". The "scenario" column is parsed as
44
JSON first, then as a Python literal for the raw HuggingFace CSV export, and
45
embedded verbatim as "source_scenario"; its exact inner keys are not pinned
46
publicly, so the merge commit is extracted liberally (keys containing
47
"merge_commit", then "hash", then "sha"). Unparseable scenario data turns
48
that sample into a skip entry with reason ``scenario_json_unparseable``.
49
50
Legacy fallback (rows WITHOUT a sample_type column -- old synthetic fixtures
51
and unknown exports; the canonical path takes precedence):
52
53
    repo           <- repo | repository | repo_name
54
    scenario_type  <- scenario_type | type | task_type
55
    commit         <- commit | sha | merge_commit_sha
56
    scenario_id    <- id | scenario_id
57
58
Mirrors are expected under ``--mirror-root`` as ``<owner>__<repo>/`` (bare or
59
worktree clones; a ``<owner>__<repo>.git/`` bare directory is also accepted).
60
A missing mirror skips that scenario with reason ``mirror_missing:<owner>/<repo>``
61
(pointer datasets rot; skip-row honesty over hard errors, mirroring
62
``scripts/mount_probe.py`` and ``scripts/oakbench/rows.py``).
63
64
Oracle modes are RECORDED here, not evaluated: ``strict-em`` / ``normalized``
65
apply to merge_conflict_resolution scenarios. interactive_rebase and
66
iterative_commit scenarios record oracle null with note
67
``oracle_defined_at_runtime:tree_equivalence`` -- their oracles are
68
tree/history equivalence checks performed by the agent lane later.
69
70
Exit codes: 0 if any scenario is ready; 3 if the dataset file is missing
71
(skip JSON ``{"status": "skipped", "reason": "dataset_missing:<path>"}`` on
72
stdout) or present but nothing is materializable; 2 on usage errors.
73
"""
74
75
from __future__ import annotations
76
77
import argparse
78
import ast
79
import csv
80
import json
81
import sys
82
from pathlib import Path
83
from typing import Any, Iterator, Optional
84
85
SCHEMA_VERSION = 2
86
SOURCE = "gitgoodbench"
87
LICENSE = "Apache-2.0"
88
CITATION = "lindenbauer-etal-2025-gitgoodbench (REALM 2025, doi:10.18653/v1/2025.realm-1.19)"
89
SOURCE_DATASETS = [
90
    "JetBrains/git_good_bench",
91
    "JetBrains/git_good_bench-lite",
92
    "JetBrains/git_good_bench-train",
93
]
94
95
EXIT_OK = 0
96
EXIT_USAGE = 2
97
EXIT_SKIP = 3
98
99
ORACLE_STRICT_EM = "strict-em"
100
ORACLE_NORMALIZED = "normalized"
101
NORMALIZATION_NORMALIZED = "whitespace_and_blank_lines"
102
ORACLE_NOTE_RUNTIME = "oracle_defined_at_runtime:tree_equivalence"
103
104
# Canonical (real published schema) columns.
105
SAMPLE_TYPE_COLUMNS = ("sample_type",)
106
NAME_COLUMNS = ("name",)
107
CANONICAL_ID_COLUMNS = ("id",)
108
109
SAMPLE_TYPE_MERGE = "merge"
110
SAMPLE_TYPE_CHAIN = "file_commit_chain"
111
112
# Legacy liberal fallback columns (old synthetic fixtures, unknown exports).
113
REPO_COLUMNS = ("repo", "repository", "repo_name")
114
TYPE_COLUMNS = ("scenario_type", "type", "task_type")
115
COMMIT_COLUMNS = ("commit", "sha", "merge_commit_sha")
116
ID_COLUMNS = ("id", "scenario_id")
117
118
TYPE_MERGE = "merge_conflict_resolution"
119
TYPE_REBASE = "interactive_rebase"
120
TYPE_ITERATIVE_COMMIT = "iterative_commit"
121
122
ROW_ERROR_KEY = "__row_error__"
123
124
125
def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
126
    parser = argparse.ArgumentParser(
127
        description="Import a GitGoodBench export (CSV or JSONL) into scenarios JSON."
128
    )
129
    parser.add_argument(
130
        "--csv",
131
        type=Path,
132
        required=True,
133
        help="Local path to a dataset export: .csv or .jsonl (sniffed by extension)",
134
    )
135
    parser.add_argument(
136
        "--mirror-root",
137
        type=Path,
138
        required=True,
139
        help="Directory containing mirrored repos as <owner>__<repo>/ (bare or worktree clones)",
140
    )
141
    parser.add_argument("--out", type=Path, required=True, help="Path to write the scenarios JSON")
142
    parser.add_argument(
143
        "--oracle",
144
        choices=[ORACLE_STRICT_EM, ORACLE_NORMALIZED],
145
        default=ORACLE_STRICT_EM,
146
        help="Oracle mode recorded per merge scenario (recorded only; evaluation lives in the agent lane)",
147
    )
148
    return parser.parse_args(argv)
149
150
151
def pick_column(row: dict[str, Any], names: tuple[str, ...]) -> Optional[str]:
152
    lowered = {str(key).strip().lower(): value for key, value in row.items() if key is not None}
153
    for name in names:
154
        value = lowered.get(name)
155
        if value is not None and str(value).strip():
156
            return str(value).strip()
157
    return None
158
159
160
def raw_field(row: dict[str, Any], name: str) -> Any:
161
    """Case-insensitive raw lookup (JSONL rows may carry non-string values)."""
162
    for key, value in row.items():
163
        if key is not None and str(key).strip().lower() == name:
164
            return value
165
    return None
166
167
168
def normalize_scenario_type(raw: Optional[str]) -> Optional[str]:
169
    if not raw:
170
        return None
171
    text = raw.strip().lower()
172
    if "merge" in text:
173
        return TYPE_MERGE
174
    if "rebase" in text:
175
        return TYPE_REBASE
176
    if "commit" in text or "iterative" in text:
177
        return TYPE_ITERATIVE_COMMIT
178
    return None
179
180
181
def mirror_path_for(mirror_root: Path, repo: str) -> Optional[Path]:
182
    """Return the existing mirror directory for owner/repo, or None."""
183
    slug = repo.strip().strip("/").replace("/", "__")
184
    for candidate in (mirror_root / slug, mirror_root / f"{slug}.git"):
185
        if candidate.is_dir():
186
            return candidate
187
    return None
188
189
190
def oracle_record(mode: str) -> dict[str, Any]:
191
    return {
192
        "mode": mode,
193
        "normalization": NORMALIZATION_NORMALIZED if mode == ORACLE_NORMALIZED else None,
194
    }
195
196
197
def _walk_scalar_items(obj: Any) -> Iterator[tuple[str, Any]]:
198
    """Depth-first (key, scalar value) pairs from nested dicts/lists."""
199
    if isinstance(obj, dict):
200
        for key, value in obj.items():
201
            if isinstance(value, (dict, list)):
202
                yield from _walk_scalar_items(value)
203
            else:
204
                yield str(key).strip().lower(), value
205
    elif isinstance(obj, list):
206
        for item in obj:
207
            yield from _walk_scalar_items(item)
208
209
210
def extract_merge_commit(scenario_obj: Any) -> Optional[str]:
211
    """Liberal extraction: keys containing merge_commit, then hash, then sha."""
212
    items = list(_walk_scalar_items(scenario_obj))
213
    for needle in ("merge_commit", "hash", "sha"):
214
        for key, value in items:
215
            if needle in key and isinstance(value, str) and value.strip():
216
                return value.strip()
217
    return None
218
219
220
def parse_scenario_field(row: dict[str, Any]) -> tuple[Any, bool]:
221
    """Parse the 'scenario' column. Returns (parsed, ok)."""
222
    raw = raw_field(row, "scenario")
223
    if isinstance(raw, (dict, list)):
224
        return raw, True  # already structured (e.g. a generous JSONL export)
225
    if raw is None or not str(raw).strip():
226
        return None, False
227
    # Both parse paths accept only structured (dict/list) results: a scalar
228
    # scenario column carries no scenario data and must skip, not go ready.
229
    try:
230
        parsed = json.loads(str(raw))
231
    except (json.JSONDecodeError, ValueError):
232
        try:
233
            parsed = ast.literal_eval(str(raw))
234
        except (SyntaxError, ValueError):
235
            return None, False
236
    if isinstance(parsed, (dict, list)):
237
        return parsed, True
238
    return None, False
239
240
241
def base_entry(
242
    scenario_id: str,
243
    scenario_type: Optional[str],
244
    sample_type: Optional[str],
245
    repo: Optional[str],
246
    metadata: Optional[dict[str, Any]],
247
) -> dict[str, Any]:
248
    return {
249
        "scenario_id": scenario_id,
250
        "scenario_type": scenario_type,
251
        "sample_type": sample_type,
252
        "repo": repo,
253
        "mirror_path": None,
254
        "commit": None,
255
        "source_scenario": None,
256
        "metadata": metadata,
257
        "oracle": None,
258
        "oracle_note": None,
259
        "status": "skipped",
260
        "skip_reason": None,
261
    }
262
263
264
def build_canonical(
265
    row: dict[str, Any],
266
    index: int,
267
    mirror_root: Path,
268
    oracle_mode: str,
269
    sample_type: str,
270
) -> list[dict[str, Any]]:
271
    """Canonical path for rows carrying the real published schema."""
272
    base_id = pick_column(row, CANONICAL_ID_COLUMNS) or f"{SOURCE}-row-{index}"
273
    repo = pick_column(row, NAME_COLUMNS)
274
    metadata = {
275
        "difficulty": pick_column(row, ("difficulty",)),
276
        "programming_language": pick_column(row, ("programming_language",)),
277
        "default_branch": pick_column(row, ("default_branch",)),
278
    }
279
280
    normalized_sample_type = sample_type.strip().lower()
281
    if normalized_sample_type == SAMPLE_TYPE_MERGE:
282
        specs = [(base_id, TYPE_MERGE)]
283
    elif normalized_sample_type == SAMPLE_TYPE_CHAIN:
284
        specs = [
285
            (f"{base_id}--rebase", TYPE_REBASE),
286
            (f"{base_id}--commits", TYPE_ITERATIVE_COMMIT),
287
        ]
288
    else:
289
        entry = base_entry(base_id, None, sample_type, repo, metadata)
290
        entry["skip_reason"] = f"unrecognized_sample_type:{sample_type}"
291
        return [entry]
292
293
    source_scenario, parsed_ok = parse_scenario_field(row)
294
    if not parsed_ok:
295
        entry = base_entry(base_id, None, sample_type, repo, metadata)
296
        entry["skip_reason"] = "scenario_json_unparseable"
297
        return [entry]
298
299
    merge_commit = extract_merge_commit(source_scenario)
300
301
    entries: list[dict[str, Any]] = []
302
    for scenario_id, scenario_type in specs:
303
        entry = base_entry(scenario_id, scenario_type, sample_type, repo, metadata)
304
        entry["source_scenario"] = source_scenario
305
        if scenario_type == TYPE_MERGE:
306
            entry["commit"] = merge_commit
307
308
        if not repo:
309
            entry["skip_reason"] = "missing_repo"
310
            entries.append(entry)
311
            continue
312
        mirror = mirror_path_for(mirror_root, repo)
313
        if mirror is None:
314
            entry["skip_reason"] = f"mirror_missing:{repo}"
315
            entries.append(entry)
316
            continue
317
318
        entry["mirror_path"] = str(mirror)
319
        entry["status"] = "ready"
320
        if scenario_type == TYPE_MERGE:
321
            entry["oracle"] = oracle_record(oracle_mode)
322
        else:
323
            entry["oracle"] = None
324
            entry["oracle_note"] = ORACLE_NOTE_RUNTIME
325
        entries.append(entry)
326
    return entries
327
328
329
def build_legacy(
330
    row: dict[str, Any],
331
    index: int,
332
    mirror_root: Path,
333
    oracle_mode: str,
334
) -> dict[str, Any]:
335
    """Legacy liberal fallback for rows without a sample_type column."""
336
    repo = pick_column(row, REPO_COLUMNS)
337
    raw_type = pick_column(row, TYPE_COLUMNS)
338
    commit = pick_column(row, COMMIT_COLUMNS)
339
    scenario_id = pick_column(row, ID_COLUMNS) or f"{SOURCE}-row-{index}"
340
    scenario_type = normalize_scenario_type(raw_type)
341
342
    entry = base_entry(scenario_id, scenario_type, None, repo, None)
343
    entry["commit"] = commit
344
345
    if scenario_type is None:
346
        entry["skip_reason"] = f"unrecognized_scenario_type:{raw_type or ''}"
347
        return entry
348
    if not repo:
349
        entry["skip_reason"] = "missing_repo"
350
        return entry
351
352
    mirror = mirror_path_for(mirror_root, repo)
353
    if mirror is None:
354
        entry["skip_reason"] = f"mirror_missing:{repo}"
355
        return entry
356
357
    entry["mirror_path"] = str(mirror)
358
    entry["status"] = "ready"
359
    if scenario_type == TYPE_MERGE:
360
        entry["oracle"] = oracle_record(oracle_mode)
361
    else:
362
        entry["oracle"] = None
363
        entry["oracle_note"] = ORACLE_NOTE_RUNTIME
364
    return entry
365
366
367
def build_from_row(
368
    row: dict[str, Any],
369
    index: int,
370
    mirror_root: Path,
371
    oracle_mode: str,
372
) -> list[dict[str, Any]]:
373
    row_error = row.get(ROW_ERROR_KEY)
374
    if row_error:
375
        entry = base_entry(f"{SOURCE}-row-{index}", None, None, None, None)
376
        entry["skip_reason"] = str(row_error)
377
        return [entry]
378
    sample_type = pick_column(row, SAMPLE_TYPE_COLUMNS)
379
    if sample_type:
380
        return build_canonical(row, index, mirror_root, oracle_mode, sample_type)
381
    return [build_legacy(row, index, mirror_root, oracle_mode)]
382
383
384
def read_rows(path: Path) -> Iterator[dict[str, Any]]:
385
    """Yield row dicts from a .csv or .jsonl export (sniffed by extension)."""
386
    if path.suffix.lower() == ".jsonl":
387
        with path.open(encoding="utf-8") as fh:
388
            for line in fh:
389
                text = line.strip()
390
                if not text:
391
                    continue
392
                try:
393
                    obj = json.loads(text)
394
                except json.JSONDecodeError:
395
                    yield {ROW_ERROR_KEY: "row_json_unparseable"}
396
                    continue
397
                if isinstance(obj, dict):
398
                    yield obj
399
                else:
400
                    yield {ROW_ERROR_KEY: "row_not_an_object"}
401
    else:
402
        with path.open(newline="", encoding="utf-8") as fh:
403
            yield from csv.DictReader(fh)
404
405
406
def import_dataset(dataset_path: Path, mirror_root: Path, oracle_mode: str) -> dict[str, Any]:
407
    scenarios: list[dict[str, Any]] = []
408
    for index, row in enumerate(read_rows(dataset_path)):
409
        scenarios.extend(build_from_row(row, index, mirror_root, oracle_mode))
410
    ready = sum(1 for item in scenarios if item["status"] == "ready")
411
    skipped = sum(1 for item in scenarios if item["status"] == "skipped")
412
    return {
413
        "schema_version": SCHEMA_VERSION,
414
        "source": SOURCE,
415
        "license": LICENSE,
416
        "citation": CITATION,
417
        "source_datasets": SOURCE_DATASETS,
418
        "oracle_mode": oracle_mode,
419
        "scenarios": scenarios,
420
        "counts": {"ready": ready, "skipped": skipped},
421
    }
422
423
424
def main(argv: Optional[list[str]] = None) -> int:
425
    args = parse_args(argv)
426
427
    if not args.csv.is_file():
428
        print(json.dumps({"status": "skipped", "reason": f"dataset_missing:{args.csv}"}))
429
        return EXIT_SKIP
430
431
    document = import_dataset(args.csv, args.mirror_root, args.oracle)
432
433
    args.out.parent.mkdir(parents=True, exist_ok=True)
434
    args.out.write_text(json.dumps(document, indent=2, sort_keys=True) + "\n")
435
    print(f"[result] {args.out}", file=sys.stderr)
436
437
    if document["counts"]["ready"] > 0:
438
        return EXIT_OK
439
    return EXIT_SKIP
440
441
442
if __name__ == "__main__":
443
    raise SystemExit(main())