File size: 5,407 Bytes
00bb63e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Does the summary preserve what is needed to continue?

Loss is the wrong headline. The failure this tune exists to fix is specific: a summary that drops
the task, after which the agent invents a new one — sixteen searches of research compacted into 396
characters, answered with "fresh project scaffolded". So the metric is preservation, not fluency.

Four checks, all deterministic, no judge:

- **has_task** — is there a `Task:` line at all. The reference summaries all have one.
- **task_overlap** — word-level F1 between the generated task line and the reference's. Catches a
  summary that states *a* task, just not this one.
- **fact_recall** — fraction of the reference's distinctive tokens (paths, identifiers, numbers,
  errors) that survive into the generated summary. This is the one that catches a fluent summary
  with the specifics sanded off, which is what a small model tends to produce.
- **compression** — length ratio. A "summary" that is 90% of the input has not compacted anything,
  and one that is 2% has thrown the state away.

Run base and tuned through the identical path; the difference is the adapter.

Usage:
    python3 eval_compaction.py --out base_eval.json
    python3 eval_compaction.py --adapter out/adapter --out tuned_eval.json
    python3 eval_compaction.py --compare base_eval.json tuned_eval.json
"""

import argparse
import json
import re
import sys

MODEL = "Lazarus-Ai/ReAligned-Qwen3.5-4B"

# Tokens worth preserving: paths, dotted identifiers, CamelCase, numbers with units, errors.
DISTINCTIVE = re.compile(
    r"(?:/[\w.\-/]{4,})"
    r"|(?:\b\w+\.(?:ts|py|json|jsonl|toml|md|sh|jinja|gguf|safetensors)\b)"
    r"|(?:\b[A-Z][a-z]+[A-Z]\w+\b)"
    r"|(?:\b\d{2,}(?:\.\d+)?[kKmMgG]?\b)"
    r"|(?:\b[A-Z][A-Z_]{3,}\b)"
)


def task_line(text: str) -> str:
    m = re.search(r"^\s*task:\s*(.+)$", text, re.I | re.M)
    return m.group(1).strip() if m else ""


def f1(a: str, b: str) -> float:
    ta = {w.lower() for w in re.findall(r"\w+", a) if len(w) > 2}
    tb = {w.lower() for w in re.findall(r"\w+", b) if len(w) > 2}
    if not ta or not tb:
        return 0.0
    hit = len(ta & tb)
    p, r = hit / len(ta), hit / len(tb)
    return 0.0 if p + r == 0 else 2 * p * r / (p + r)


def score(generated: str, reference: str, prompt: str) -> dict:
    ref_facts = set(DISTINCTIVE.findall(reference))
    gen_facts = set(DISTINCTIVE.findall(generated))
    return {
        "has_task": bool(task_line(generated)),
        "task_overlap": round(f1(task_line(generated), task_line(reference)), 3),
        "fact_recall": round(len(ref_facts & gen_facts) / len(ref_facts), 3) if ref_facts else None,
        "compression": round(len(generated) / max(len(prompt), 1), 4),
        "chars": len(generated),
    }


def run(args) -> None:
    import torch
    from transformers import AutoModelForImageTextToText, AutoTokenizer

    tok = AutoTokenizer.from_pretrained(MODEL)
    tok.padding_side = "left"
    if tok.pad_token_id is None:
        tok.pad_token = tok.eos_token

    model = AutoModelForImageTextToText.from_pretrained(
        MODEL, dtype=torch.bfloat16, device_map="cuda:0")
    if args.adapter:
        from peft import PeftModel
        model = PeftModel.from_pretrained(model, args.adapter)
    model.eval()

    rows = [json.loads(l) for l in open(args.val)][: args.limit]
    results = []
    for i, r in enumerate(rows):
        enc = tok(r["prompt"], return_tensors="pt").to(model.device)
        with torch.no_grad():
            out = model.generate(**enc, max_new_tokens=1200, do_sample=False,
                                 pad_token_id=tok.pad_token_id)
        gen = tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()
        s = score(gen, r["chosen"], r["prompt"])
        s["text"] = gen[:1500]
        results.append(s)
        print(f"[{i+1}/{len(rows)}] task={s['has_task']} overlap={s['task_overlap']} "
              f"facts={s['fact_recall']} chars={s['chars']}", flush=True)

    summarise(results, args.adapter or "base")
    json.dump({"adapter": args.adapter, "results": results}, open(args.out, "w"), indent=1)


def summarise(results: list, label: str) -> None:
    n = len(results)
    has = sum(r["has_task"] for r in results)
    ov = [r["task_overlap"] for r in results]
    fr = [r["fact_recall"] for r in results if r["fact_recall"] is not None]
    comp = [r["compression"] for r in results]
    print(f"\n--- {label} ({n} held-out windows) ---")
    print(f"  has Task: line   {has}/{n}  ({100*has/n:.0f}%)")
    print(f"  task overlap     {sum(ov)/n:.3f} mean")
    print(f"  fact recall      {sum(fr)/len(fr):.3f} mean" if fr else "  fact recall n/a")
    print(f"  compression      {sum(comp)/n:.3f} mean  (reference ~0.12)")
    print(f"  mean chars       {sum(r['chars'] for r in results)//n}")


def compare(a: str, b: str) -> int:
    for path in (a, b):
        d = json.load(open(path))
        summarise(d["results"], d.get("adapter") or "base")
    return 0


if __name__ == "__main__":
    ap = argparse.ArgumentParser()
    ap.add_argument("--adapter")
    ap.add_argument("--val", default="compaction_val.jsonl")
    ap.add_argument("--limit", type=int, default=45)
    ap.add_argument("--out", default="compaction_eval.json")
    ap.add_argument("--compare", nargs=2)
    a = ap.parse_args()
    sys.exit(compare(*a.compare) if a.compare else run(a))