Spaces:
Running
Running
Download build_data.py from lightonai/colgrep-agent: direct link, hf CLI and curl.
- Browser
- Download file 19.6 kB
-
https://huggingface.co/spaces/lightonai/colgrep-agent/resolve/main/build_data.py
- Command line
-
hf download hf://spaces/lightonai/colgrep-agent/build_data.py
-
curl -L -o build_data.py https://huggingface.co/spaces/lightonai/colgrep-agent/resolve/main/build_data.py
19.6 kB
| """Convert the harness trajectory export (JSONL) into the compact JSON the replay Space loads. | |
| Usage: | |
| python fetch_trees.py # once: metadata + repository trees | |
| python build_data.py minicpm5_2b_rl_fileline_turn02_trajectories.jsonl | |
| Input: one or more JSONL files, one trajectory per line, schema_version 1 of the | |
| evaluation harness export: {trajectory_id, model, benchmark, seed, instance_id, repo, | |
| language, evaluation, messages, final_prediction, gold, scores, outcome, usage}. | |
| ``messages`` is the OpenAI-style chat transcript (system, user, assistant with | |
| tool_calls, tool). Unlike the earlier grep/colgrep export, this one carries the gold | |
| spans, the harness's parsed prediction and its file- and line-level scores, so none of | |
| them are recomputed here. Base commits and repository trees still come from | |
| ``raw/instances.json`` and ``raw/trees/`` (see fetch_trees.py). | |
| Output (in ./data): | |
| index.json one row per instance with summary metrics | |
| traj/<id>.json full replay for one instance: {..., "run": {...}} | |
| Only seed 1 is kept so that each instance has one trajectory. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import pathlib | |
| import re | |
| import sys | |
| # ---------------------------------------------------------------- configuration | |
| HERE = pathlib.Path(__file__).parent | |
| OUT_DIR = HERE / "data" | |
| RAW_DIR = HERE / "raw" | |
| KEEP_SEEDS = {1} | |
| MODEL_NAME = "colgrep-agent-2B" | |
| MODEL_URL = "https://huggingface.co/lightonai/colgrep-agent-2B" | |
| # Curated examples shown at the top of the picker. Order matters. The note says what to | |
| # watch for; it is written from the recorded seed-1 trajectories, not from the paper. | |
| FEATURED: list[tuple[str, str]] = [ | |
| ( | |
| "django__django-11099", | |
| "Three turns, exact lines (Python). One query, one read of validators.py, then two single-line ranges: " | |
| "lines 10 and 20, the two regex literals the fix changes.", | |
| ), | |
| ( | |
| "pydata__xarray-4248", | |
| "File found, lines missed (Python). formatting.py is the first result of the first query and the agent reads it three times, " | |
| "but at lines 1-100, 386-391 and 495-515. It submits dataset_repr (495-515); the gold line is 263.", | |
| ), | |
| ( | |
| "matplotlib__matplotlib-25332", | |
| "A miss (Python). The gold file cbook.py surfaces at turn 5 under a weakref query and is never opened. The agent repeats " | |
| "the _align_label_groups query at turns 6, 8 and 9, and the harness forces a finish at turn 10 on figure.py.", | |
| ), | |
| ( | |
| "cli__cli-405", | |
| "Two gold files (Go). The first query, printPrPreview, lists pr.go and issue.go. The agent reads the two preview " | |
| "functions, confirms the RenderMarkdown call with a second query, and submits one line in each file, both inside the gold spans.", | |
| ), | |
| ( | |
| "serde-rs__serde-2798", | |
| "Two gold files (Rust). One query lists ser.rs and check.rs with the right ranges; two reads and a finish at turn 4. " | |
| "The submitted ranges fall inside the gold spans but cover a fifth of their lines (line recall 0.22).", | |
| ), | |
| ( | |
| "darkreader__darkreader-7241", | |
| "Exact line hit in 4 turns (TypeScript). The first query's top result is parse.ts:146, the gold line. " | |
| "The agent reads the file twice to confirm and submits that single line.", | |
| ), | |
| ( | |
| "jqlang__jq-1793", | |
| "Exact line hit (C). The first query lists jv.c:1336-1352 first, but the agent opens builtin.c first, then jv.c, " | |
| "and submits line 1345, the gold line.", | |
| ), | |
| ( | |
| "fmtlib__fmt-2394", | |
| "One gold file, five gold spans (C++). The query on the emphasis enum lands on color.h:186. The agent reads the enum and " | |
| "the escape-sequence builder and submits three ranges covering most of the gold lines (line F1 0.81).", | |
| ), | |
| ( | |
| "anuraghazra__github-readme-stats-1378", | |
| "Two gold files (JavaScript). The first query lists both; the agent reads them whole, then narrows with a second query on " | |
| "wrapTextMultiline. It submits the right block of utils.js but the wrong line of repo-card.js.", | |
| ), | |
| ( | |
| "google__gson-1555", | |
| "Two gold files, eight gold spans (Java). Both files appear in the first listing. Three whole-file reads and a second query " | |
| "give three submitted ranges; file F1 1.0, line F1 0.38.", | |
| ), | |
| ] | |
| # ------------------------------------------------------------------------------ | |
| def parse_args(raw: str) -> dict: | |
| try: | |
| return json.loads(raw) | |
| except json.JSONDecodeError: | |
| return {"raw": raw} | |
| def describe_call(tool_name: str, args: dict) -> dict: | |
| """Classify a tool call for display: search, read, or finish.""" | |
| if tool_name == "finish": | |
| locs = args.get("locations") or [] | |
| return {"kind": "finish", "locations": [str(l) for l in locs]} | |
| if tool_name == "colgrep": | |
| return { | |
| "kind": "search", | |
| "tool": "colgrep", | |
| "query": args.get("query"), | |
| "pattern": args.get("pattern"), | |
| "path": args.get("path"), | |
| "include": args.get("include"), | |
| "k": args.get("k"), | |
| "command": args.get("command"), | |
| } | |
| cmd = (args.get("command") or args.get("raw") or "").strip() | |
| first = cmd.split()[0] if cmd else "" | |
| if first in {"grep", "rg", "find", "ag", "colgrep"}: | |
| return {"kind": "search", "tool": first, "command": cmd} | |
| return {"kind": "read", "tool": "terminal", "command": cmd} | |
| PATH_TOKEN = re.compile( | |
| r"(?<![\w/])\.?/?((?:[\w.-]+/)*[\w.-]+\.[A-Za-z0-9]{1,8})(?![\w/])" | |
| ) | |
| # colgrep result header: "[3] path/to/file.py:120-141 (function name)" | |
| RESULT_LINE = re.compile(r"^\[\d+\] (\S+?):(\d+)-(\d+)", re.M) | |
| # cat -n output line: " 120\t..." | |
| NUMBERED_LINE = re.compile(r"^\s*(\d+)\t", re.M) | |
| def paths_in(text: str, tree_paths: set[str]) -> list[str]: | |
| """Repository files mentioned in a text, in order of first appearance.""" | |
| seen: list[str] = [] | |
| for m in PATH_TOKEN.finditer(text): | |
| p = m.group(1).removeprefix("./") | |
| if p in tree_paths and p not in seen: | |
| seen.append(p) | |
| return seen | |
| def span_overlap(a: tuple[int, int], b: tuple[int, int]) -> bool: | |
| return a[0] <= b[1] and b[0] <= a[1] | |
| def spans_in_search_output(text: str) -> list[tuple[str, int, int]]: | |
| return [ | |
| (m.group(1), int(m.group(2)), int(m.group(3))) | |
| for m in RESULT_LINE.finditer(text) | |
| ] | |
| def lines_in_read_output(text: str) -> tuple[int, int] | None: | |
| nums = [int(m.group(1)) for m in NUMBERED_LINE.finditer(text)] | |
| return (min(nums), max(nums)) if nums else None | |
| def gold_span_hits( | |
| spans: list[tuple[str, int, int]], gold_spans: list[dict] | |
| ) -> list[str]: | |
| """Gold spans (as 'file:start-end') overlapped by any of the given spans.""" | |
| hits = [] | |
| for g in gold_spans: | |
| key = f"{g['file']}:{g['start_line']}-{g['end_line']}" | |
| for f, lo, hi in spans: | |
| if f == g["file"] and span_overlap( | |
| (lo, hi), (g["start_line"], g["end_line"]) | |
| ): | |
| hits.append(key) | |
| break | |
| return hits | |
| def load_tree(instance_id: str) -> list[str]: | |
| f = RAW_DIR / "trees" / f"{instance_id}.json" | |
| return json.load(open(f))["paths"] if f.exists() else [] | |
| def dir_skeleton(paths: list[str], shown: set[str]) -> dict: | |
| """Nested directory skeleton with recursive file counts. | |
| Every directory is kept (with its file count) so the viewer can show the shape | |
| of the repository; files are listed only when they are in ``shown`` (gold, | |
| searched, read, or submitted), which keeps the payload small. | |
| """ | |
| root: dict = {"n": 0, "d": {}, "f": []} | |
| for p in paths: | |
| parts = p.split("/") | |
| node = root | |
| node["n"] += 1 | |
| for part in parts[:-1]: | |
| node = node["d"].setdefault(part, {"n": 0, "d": {}, "f": []}) | |
| node["n"] += 1 | |
| if p in shown: | |
| node["f"].append(parts[-1]) | |
| def prune(node: dict) -> dict: | |
| out = {"n": node["n"]} | |
| if node["d"]: | |
| out["d"] = {k: prune(v) for k, v in sorted(node["d"].items())} | |
| if node["f"]: | |
| out["f"] = sorted(node["f"]) | |
| return out | |
| return prune(root) | |
| def split_problem(user_message: str) -> str: | |
| """Problem statement without the harness prefix, the repository listing and the final instruction.""" | |
| stmt = user_message | |
| for marker in ( | |
| "\n\nYou are at the repository root.", | |
| "\n\nFind the code locations relevant to this problem.", | |
| ): | |
| if marker in stmt: | |
| stmt = stmt.split(marker)[0] | |
| return stmt.removeprefix("Problem statement:\n").strip() | |
| def convert_run(doc: dict, tree_paths: set[str]) -> dict: | |
| gold_files: list[str] = list(doc["gold"]["files"]) | |
| gold_spans: list[dict] = list(doc["gold"]["spans"]) | |
| turns: list[dict] = [] | |
| system_prompt = "" | |
| problem = "" | |
| found_files: set[str] = set() | |
| found_spans: set[str] = set() | |
| pending_notes: list[str] = [] # harness messages addressed to the next turn | |
| for m in doc["messages"]: | |
| if m["role"] == "system": | |
| system_prompt = m["content"] | |
| elif m["role"] == "user": | |
| if not problem: | |
| problem = m["content"] | |
| else: | |
| pending_notes.append((m.get("content") or "").strip()) | |
| elif m["role"] == "assistant": | |
| calls = [ | |
| describe_call( | |
| tc["function"]["name"], parse_args(tc["function"]["arguments"]) | |
| ) | |
| for tc in m.get("tool_calls") or [] | |
| ] | |
| read_paths: list[str] = [] | |
| for c in calls: | |
| if c["kind"] == "read" and c.get("command"): | |
| read_paths += [ | |
| p | |
| for p in paths_in(c["command"], tree_paths) | |
| if p not in read_paths | |
| ] | |
| turns.append( | |
| { | |
| "n": len(turns) + 1, | |
| "notes": pending_notes, | |
| "thought": (m.get("content") or "").strip(), | |
| "calls": calls, | |
| "observations": [], | |
| "touched": {"searched": [], "read": read_paths}, | |
| } | |
| ) | |
| pending_notes = [] | |
| elif m["role"] == "tool" and turns: | |
| text = m.get("content") or "" | |
| # Strip the harness turn-counter footer; the viewer numbers turns itself. | |
| text = re.sub( | |
| r"\n*\[This is the output of turn \d+ \(\d+ turn limit\)\.\]\s*$", | |
| "", | |
| text, | |
| ) | |
| k = len(turns[-1]["observations"]) | |
| call = turns[-1]["calls"][k] if k < len(turns[-1]["calls"]) else None | |
| file_hits = [g for g in gold_files if g in text] | |
| new_files = [g for g in file_hits if g not in found_files] | |
| found_files.update(file_hits) | |
| # Gold lines: a search result range or a numbered read range that overlaps a gold span. | |
| span_hits: list[str] = [] | |
| if call and call["kind"] == "search": | |
| span_hits = gold_span_hits(spans_in_search_output(text), gold_spans) | |
| elif call and call["kind"] == "read": | |
| rng = lines_in_read_output(text) | |
| files = turns[-1]["touched"]["read"] | |
| if rng and len(files) == 1: | |
| span_hits = gold_span_hits([(files[0], rng[0], rng[1])], gold_spans) | |
| new_spans = [s for s in span_hits if s not in found_spans] | |
| found_spans.update(span_hits) | |
| turns[-1]["observations"].append( | |
| { | |
| "text": text, | |
| "gold_hits": file_hits, | |
| "new_gold": new_files, | |
| "span_hits": span_hits, | |
| "new_span": new_spans, | |
| } | |
| ) | |
| # Files surfaced by this observation (search hits or listings). Only | |
| # observations of search calls count as "searched"; read output is the | |
| # content of a file already recorded under "read". | |
| if call and call["kind"] == "search": | |
| t = turns[-1]["touched"] | |
| t["searched"] += [ | |
| p | |
| for p in paths_in(text, tree_paths) | |
| if p not in t["searched"] and p not in t["read"] | |
| ] | |
| if pending_notes and turns: | |
| turns[-1]["notes"] = turns[-1]["notes"] + pending_notes | |
| pred = doc["final_prediction"] | |
| locations = [ | |
| {"file": l["file"], "start": l["start_line"], "end": l["end_line"]} | |
| for l in pred.get("locations") or [] | |
| ] | |
| pred_files: list[str] = [] | |
| for l in locations: | |
| if l["file"] not in pred_files: | |
| pred_files.append(l["file"]) | |
| finish_turn = next( | |
| (t["n"] for t in turns if any(c["kind"] == "finish" for c in t["calls"])), None | |
| ) | |
| forced = finish_turn is not None and any( | |
| "final turn" in n.lower() for n in turns[finish_turn - 1]["notes"] | |
| ) | |
| s = doc["scores"] | |
| touched_all = { | |
| p for t in turns for k in ("searched", "read") for p in t["touched"][k] | |
| } | |
| return { | |
| "search_tool": doc["evaluation"]["search_tool"], | |
| "model": doc["model"], | |
| "evaluation": doc["evaluation"], | |
| "seed": doc["seed"], | |
| "problem": split_problem(problem), | |
| "system_prompt": system_prompt, | |
| "gold": gold_files, | |
| "gold_spans": [ | |
| {"file": g["file"], "start": g["start_line"], "end": g["end_line"]} | |
| for g in gold_spans | |
| ], | |
| "predicted": pred_files, | |
| "locations": locations, | |
| "metrics": { | |
| "f1": round(s["file_f1"], 4), | |
| "precision": round(s["file_precision"], 4), | |
| "recall": round(s["file_recall"], 4), | |
| "line_f1": None if s["line_f1"] is None else round(s["line_f1"], 4), | |
| "line_precision": None | |
| if s["line_precision"] is None | |
| else round(s["line_precision"], 4), | |
| "line_recall": None | |
| if s["line_recall"] is None | |
| else round(s["line_recall"], 4), | |
| }, | |
| "n_turns": len(turns), | |
| "n_searches": sum( | |
| 1 for t in turns for c in t["calls"] if c["kind"] == "search" | |
| ), | |
| "n_reads": sum(1 for t in turns for c in t["calls"] if c["kind"] == "read"), | |
| "n_files_touched": len(touched_all), | |
| "completion_tokens": doc["usage"].get("completion_tokens"), | |
| "end_reason": doc["outcome"]["end_reason"], | |
| "finish_turn": finish_turn, | |
| "forced_finish": forced, | |
| "turns": turns, | |
| } | |
| def main(paths: list[str]) -> None: | |
| meta_by_id = json.load(open(RAW_DIR / "instances.json")) | |
| by_instance: dict[str, dict] = {} | |
| skipped = 0 | |
| missing_meta: set[str] = set() | |
| for path in paths: | |
| with open(path) as fh: | |
| for line in fh: | |
| doc = json.loads(line) | |
| if doc["seed"] not in KEEP_SEEDS: | |
| skipped += 1 | |
| continue | |
| iid = doc["instance_id"] | |
| if iid not in meta_by_id: | |
| missing_meta.add(iid) | |
| skipped += 1 | |
| continue | |
| md = meta_by_id[iid] | |
| if sorted(md["gold"]) != sorted(doc["gold"]["files"]): | |
| print( | |
| "gold files differ from raw/instances.json for", | |
| iid, | |
| file=sys.stderr, | |
| ) | |
| tree_paths = load_tree(iid) | |
| run = convert_run(doc, set(tree_paths)) | |
| by_instance[iid] = { | |
| "instance_id": iid, | |
| "benchmark": doc["benchmark"], | |
| "repo": md["repo"], | |
| "github_repo": md["repo"], | |
| "base_commit": md["base_commit"], | |
| "language": md["language"], | |
| "title": run["problem"].splitlines()[0].strip() | |
| if run["problem"] | |
| else iid, | |
| "gold": run["gold"], | |
| "gold_spans": run["gold_spans"], | |
| "run": run, | |
| "tree_paths": tree_paths, | |
| } | |
| (OUT_DIR / "traj").mkdir(parents=True, exist_ok=True) | |
| for old in (OUT_DIR / "traj").glob("*.json"): | |
| old.unlink() | |
| index = [] | |
| for iid, inst in sorted(by_instance.items()): | |
| paths = inst.pop("tree_paths") | |
| r = inst["run"] | |
| if paths: | |
| shown = set(inst["gold"]) | set(r["predicted"]) | |
| for t in r["turns"]: | |
| shown.update(t["touched"]["searched"]) | |
| shown.update(t["touched"]["read"]) | |
| inst["repo_tree"] = dir_skeleton(paths, shown) | |
| inst["n_files"] = len(paths) | |
| with open(OUT_DIR / "traj" / f"{iid}.json", "w") as fh: | |
| json.dump(inst, fh, ensure_ascii=False, separators=(",", ":")) | |
| row = { | |
| k: inst[k] | |
| for k in ("instance_id", "benchmark", "repo", "language", "title", "gold") | |
| } | |
| row["n_gold_spans"] = len(inst["gold_spans"]) | |
| row["n_files"] = inst.get("n_files") | |
| row["f1"] = r["metrics"]["f1"] | |
| row["line_f1"] = r["metrics"]["line_f1"] | |
| row["n_turns"] = r["n_turns"] | |
| row["n_searches"] = r["n_searches"] | |
| row["n_reads"] = r["n_reads"] | |
| row["touched"] = r["n_files_touched"] | |
| row["forced_finish"] = r["forced_finish"] | |
| index.append(row) | |
| featured = [{"instance_id": i, "note": n} for i, n in FEATURED if i in by_instance] | |
| missing_featured = [i for i, _ in FEATURED if i not in by_instance] | |
| any_run = next(iter(by_instance.values()))["run"] | |
| meta = { | |
| "n_instances": len(index), | |
| "benchmarks": { | |
| b: sum(1 for i in index if i["benchmark"] == b) | |
| for b in sorted({i["benchmark"] for i in index}) | |
| }, | |
| "tool": any_run["search_tool"], | |
| "model_name": MODEL_NAME, | |
| "model_url": MODEL_URL, | |
| "model": any_run["model"], | |
| "evaluation": any_run["evaluation"], | |
| "seeds": sorted(KEEP_SEEDS), | |
| "with_tree": sum(1 for i in by_instance.values() if i.get("n_files")), | |
| "skipped_docs": skipped, | |
| "featured": featured, | |
| } | |
| with open(OUT_DIR / "index.json", "w") as fh: | |
| json.dump( | |
| {"meta": meta, "instances": index}, | |
| fh, | |
| ensure_ascii=False, | |
| separators=(",", ":"), | |
| ) | |
| print(json.dumps({k: v for k, v in meta.items() if k != "featured"}, indent=2)) | |
| if missing_meta: | |
| print( | |
| "no metadata for", | |
| len(missing_meta), | |
| "instances:", | |
| sorted(missing_meta)[:10], | |
| ) | |
| if missing_featured: | |
| print("featured instances not in the export:", missing_featured) | |
| for b in meta["benchmarks"]: | |
| rows = [i for i in index if i["benchmark"] == b] | |
| n = len(rows) | |
| print( | |
| f"{b:24s} mean file F1 {100 * sum(i['f1'] for i in rows) / n:5.1f}" | |
| f" mean line F1 {100 * sum(i['line_f1'] or 0 for i in rows) / n:5.1f}" | |
| f" forced finish {sum(i['forced_finish'] for i in rows):3d} n={n}" | |
| ) | |
| if __name__ == "__main__": | |
| main(sys.argv[1:]) | |