File size: 13,504 Bytes
4f41179
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
#!/usr/bin/env python3
"""Benchmark a generated adapter against the frozen base model on ONE repo.

Builds the question set from the repo's ACTUAL contents (deps, test framework,
packaging, layout, license, entry points) rather than hand-written guesses, so
the gold answers are ground truth rather than opinion. Then scores base vs
adapted on the same model instance, adapter toggled with `disable_adapter()`.

Three task families, because an adapter can help one and hurt another -- which
is exactly what happened here (QA improved hugely while raw-text modelling
regressed), and a single number would have hidden it:

  FACT   - "Q: <question>\\nA:" -> short factual answer. The trained format.
  CODE   - completion of real lines taken from the repo's own source.
  TEXT   - plain continuation of repo prose (README/docstrings).

Metrics per family:
  loss      teacher-forced cross-entropy on the gold answer (lower better)
  win rate  fraction of items where adapted loss < base loss
  keyword   fraction of greedy generations containing the gold keyword

Usage:
    python benchmark_repo.py --job <jobId> --checkpoint ../../runs/h200_run/head.best.pt
"""
from __future__ import annotations

import argparse
import json
import re
import sys
from pathlib import Path

import numpy as np
import torch

HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE.parent / "engine"))
import config  # noqa: E402
from generate_and_merge import (TARGET_MODULES, load_head,  # noqa: E402
                                _lora_modules_by_spec_name)
from memory_lora.core import DEFAULT_ROOT_PREFIX, get_module_specs  # noqa: E402

SKIP = {".git", "__pycache__", ".venv", "node_modules", "build", "dist", ".tox"}


# --------------------------------------------------------------------------
# Build the benchmark from repo ground truth
# --------------------------------------------------------------------------

def _read(p: Path, n: int = 20000) -> str:
    try:
        return p.read_text(errors="ignore")[:n]
    except OSError:
        return ""


def build_fact_items(repo: Path) -> list[dict]:
    """Derive Q/A pairs whose answers are verifiable from the repo itself."""
    items: list[dict] = []
    pyproject = _read(repo / "pyproject.toml")
    setup_py = _read(repo / "setup.py")
    setup_cfg = _read(repo / "setup.cfg")
    build = pyproject + setup_py + setup_cfg

    # packaging backend
    if "setuptools" in build:
        items.append(dict(q="What packaging tool does this project use?",
                          a=" setuptools", kw="setuptools"))
    elif "poetry" in build.lower():
        items.append(dict(q="What packaging tool does this project use?",
                          a=" poetry", kw="poetry"))
    if "hatchling" in build:
        items.append(dict(q="What build backend does this project declare?",
                          a=" hatchling", kw="hatchling"))

    # test framework
    test_files = [p for p in repo.rglob("*.py")
                  if not any(s in p.parts for s in SKIP)
                  and ("test" in p.name.lower() or "tests" in p.parts)]
    joined = " ".join(_read(p, 4000) for p in test_files[:12])
    if "pytest" in joined or "pytest" in build:
        items.append(dict(q="What testing framework does this repository use?",
                          a=" pytest", kw="pytest"))
    elif "unittest" in joined:
        items.append(dict(q="What testing framework does this repository use?",
                          a=" unittest", kw="unittest"))

    # license
    lic = _read(repo / "LICENSE") + _read(repo / "LICENSE.txt")
    for name, key in (("Apache", "Apache"), ("MIT", "MIT"),
                      ("BSD", "BSD"), ("GNU", "GPL")):
        if name.lower() in lic.lower()[:400]:
            items.append(dict(q="What license does this project use?",
                              a=f" {key}", kw=key))
            break

    # top-level package
    EXCL = {"tests", "test", "docs", "doc", "examples", "example", "scripts",
            "benchmarks", "ext"}
    pkgs = [d.name for d in repo.iterdir()
            if d.is_dir() and (d / "__init__.py").exists()
            and d.name not in SKIP and d.name.lower() not in EXCL]
    if pkgs:
        # Prefer the package named after the repo (src/<name> layouts included).
        best = next((k for k in pkgs if k.lower() == repo.name.lower()), pkgs[0])
        items.append(dict(q="What is the name of the main Python package in this repository?",
                          a=f" {best}", kw=best))

    # dependencies
    # Parse only INSIDE a dependency list, otherwise setup.py keywords such as
    # `install_requires=` / `python_requires=` get matched as package names.
    dep_block = ""
    for pat in (r"install_requires\s*=\s*\[(.*?)\]",
                r"dependencies\s*=\s*\[(.*?)\]",
                r"\[project\.dependencies\](.*?)(?:\n\[|\Z)"):
        m = re.search(pat, build, re.S)
        if m:
            dep_block = m.group(1)
            break
    NOT_PKG = {"python", "name", "version", "requires", "install", "extras",
               "setup", "packages", "classifiers"}
    deps = re.findall(r"['\"]([A-Za-z][A-Za-z0-9_.-]{2,})\s*[><=~!\[]", dep_block)
    deps = [d for d in deps if d.lower() not in NOT_PKG]
    if deps:
        items.append(dict(q="Name a runtime dependency of this project.",
                          a=f" {deps[0]}", kw=deps[0]))

    # CI
    ci = list((repo / ".github" / "workflows").glob("*.y*ml")) if (repo / ".github" / "workflows").exists() else []
    if ci:
        items.append(dict(q="What CI system does this repository use?",
                          a=" GitHub Actions", kw="GitHub Actions"))

    # docs
    if (repo / "docs").is_dir():
        conf = _read(repo / "docs" / "conf.py")
        if "sphinx" in conf.lower() or (repo / "docs" / "conf.py").exists():
            items.append(dict(q="What documentation tool does this project use?",
                              a=" Sphinx", kw="Sphinx"))
    return items


def build_code_items(repo: Path, n: int = 12) -> list[dict]:
    """Split real source lines: prefix -> the rest of the line."""
    items: list[dict] = []
    srcs = [p for p in repo.rglob("*.py")
            if not any(s in p.parts for s in SKIP)
            and "test" not in p.name.lower()]
    for p in srcs[:40]:
        text = _read(p, 12000)
        lines = [l for l in text.splitlines()
                 if 30 < len(l) < 110 and not l.strip().startswith("#")
                 and ("(" in l or "=" in l or "import" in l)]
        for l in lines[:2]:
            cut = max(len(l) // 2, l.find("(") + 1 if "(" in l else len(l) // 2)
            prefix, target = l[:cut], l[cut:]
            if len(target.strip()) < 4:
                continue
            items.append(dict(prefix=f"# file: {p.name}\n{prefix}", target=target))
            if len(items) >= n:
                return items
    return items


def build_text_items(repo: Path, n: int = 6) -> list[dict]:
    items: list[dict] = []
    for name in ("README.md", "README.rst", "HISTORY.md", "CHANGELOG.md"):
        t = _read(repo / name, 6000)
        if len(t) < 600:
            continue
        chunks = [c for c in t.split("\n\n") if len(c) > 200][:3]
        for c in chunks:
            half = len(c) // 2
            items.append(dict(prefix=c[:half], target=c[half:half + 300]))
            if len(items) >= n:
                return items
    return items


# --------------------------------------------------------------------------
# Scoring
# --------------------------------------------------------------------------

@torch.no_grad()
def item_loss(model, tok, prefix: str, target: str, device: str) -> float:
    pid = tok(prefix, add_special_tokens=False)["input_ids"]
    tid = tok(target, add_special_tokens=False)["input_ids"]
    if not tid or not pid:
        return float("nan")
    ids = torch.tensor([pid + tid], device=device)
    labels = torch.tensor([[-100] * len(pid) + tid], device=device)
    return float(model(input_ids=ids, labels=labels).loss)


@torch.no_grad()
def generate(model, tok, prompt: str, device: str, max_new: int = 24) -> str:
    enc = tok(prompt, return_tensors="pt").to(device)
    out = model.generate(**enc, max_new_tokens=max_new, do_sample=False,
                         pad_token_id=tok.pad_token_id or tok.eos_token_id)
    return tok.decode(out[0][enc["input_ids"].shape[1]:],
                      skip_special_tokens=True).split("\n")[0]


def run_family(model, tok, items, device, name, generate_kw=False):
    rows = []
    for it in items:
        prefix = it.get("prefix") or f"Q: {it['q']}\nA:"
        target = it.get("target") or it["a"]
        with model.disable_adapter():
            lb = item_loss(model, tok, prefix, target, device)
            gb = generate(model, tok, prefix, device) if generate_kw else ""
        la = item_loss(model, tok, prefix, target, device)
        ga = generate(model, tok, prefix, device) if generate_kw else ""
        rows.append(dict(prefix=prefix, target=target, kw=it.get("kw", ""),
                         base=lb, adapted=la, gen_base=gb, gen_adapted=ga))
    valid = [r for r in rows if not (np.isnan(r["base"]) or np.isnan(r["adapted"]))]
    if not valid:
        return None
    mb = float(np.mean([r["base"] for r in valid]))
    ma = float(np.mean([r["adapted"] for r in valid]))
    wins = sum(1 for r in valid if r["adapted"] < r["base"])
    out = dict(family=name, n=len(valid), base=mb, adapted=ma,
               delta=ma - mb, win_rate=wins / len(valid))
    if generate_kw:
        kb = sum(1 for r in valid if r["kw"] and r["kw"].lower() in r["gen_base"].lower())
        ka = sum(1 for r in valid if r["kw"] and r["kw"].lower() in r["gen_adapted"].lower())
        nk = sum(1 for r in valid if r["kw"])
        out["kw_base"] = kb / max(nk, 1)
        out["kw_adapted"] = ka / max(nk, 1)
        out["nk"] = nk
    return out, rows


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--job", required=True)
    ap.add_argument("--checkpoint", default=str(config.DEFAULT_CHECKPOINT))
    ap.add_argument("--device", default="cpu")
    ap.add_argument("--show-generations", action="store_true")
    args = ap.parse_args()

    from peft import LoraConfig, get_peft_model
    from transformers import AutoModelForImageTextToText, AutoTokenizer

    ws = config.workspace(args.job)
    repo = ws / "repo"
    if not repo.exists():
        print(f"repo clone missing at {repo}", file=sys.stderr)
        sys.exit(1)
    repo_url = json.loads((ws / "status.json").read_text()).get("repo_url", "?")
    emb = np.load(ws / "embedding.npy").astype("float32")
    device = config.resolve_device(args.device)

    facts = build_fact_items(repo)
    codes = build_code_items(repo)
    texts = build_text_items(repo)
    print(f"repo:       {repo_url}")
    print(f"checkpoint: {args.checkpoint}")
    print(f"benchmark:  {len(facts)} FACT, {len(codes)} CODE, {len(texts)} TEXT items\n",
          flush=True)

    head, cfg, alpha = load_head(Path(args.checkpoint))
    with torch.no_grad():
        head_out = head(torch.from_numpy(emb).unsqueeze(0))

    tok = AutoTokenizer.from_pretrained(config.BASE_MODEL)
    if tok.pad_token is None:
        tok.pad_token = tok.eos_token
    base = AutoModelForImageTextToText.from_pretrained(
        config.BASE_MODEL, torch_dtype=torch.float32, low_cpu_mem_usage=True)
    specs = get_module_specs(base, TARGET_MODULES, root_prefix=DEFAULT_ROOT_PREFIX)
    type_of = {s.full_name: s.type for s in specs}
    model = get_peft_model(base, LoraConfig(
        r=cfg["rank"], lora_alpha=alpha,
        target_modules=[s.full_name for s in specs], lora_dropout=0.0, bias="none"))
    mods = _lora_modules_by_spec_name(model)
    with torch.no_grad():
        for sp in specs:
            m = mods.get(sp.full_name)
            if m is None:
                continue
            t = type_of[sp.full_name]
            m.lora_A["default"].weight.copy_(head_out["A"][t][0].float())
            m.lora_B["default"].weight.copy_(head_out["B"][t][0].float())
    model.to(device)
    model.eval()

    results = []
    all_rows = {}
    for items, name, gk in ((facts, "FACT", True), (codes, "CODE", False),
                            (texts, "TEXT", False)):
        if not items:
            continue
        r = run_family(model, tok, items, device, name, generate_kw=gk)
        if r:
            res, rows = r
            results.append(res)
            all_rows[name] = rows

    print(f"{'family':<7} {'n':>3} {'base':>8} {'adapted':>8} {'delta':>9} {'win%':>6}")
    print("-" * 46)
    for r in results:
        print(f"{r['family']:<7} {r['n']:>3} {r['base']:>8.4f} {r['adapted']:>8.4f} "
              f"{r['delta']:>+9.4f} {100*r['win_rate']:>5.0f}%")
    for r in results:
        if "kw_base" in r:
            print(f"\nFACT keyword accuracy over {r['nk']} verifiable answers:")
            print(f"  base    {100*r['kw_base']:.0f}%")
            print(f"  adapted {100*r['kw_adapted']:.0f}%")

    if args.show_generations and "FACT" in all_rows:
        print("\n--- FACT generations ---")
        for row in all_rows["FACT"]:
            q = row["prefix"].replace("Q: ", "").replace("\nA:", "")
            print(f"Q: {q}\n  gold:    {row['target'].strip()}")
            print(f"  base:    {row['gen_base'].strip()[:110]}")
            print(f"  adapted: {row['gen_adapted'].strip()[:110]}\n")


if __name__ == "__main__":
    main()