nbeerbower commited on
Commit
00bb63e
·
verified ·
1 Parent(s): f0073a7

Compaction summariser: 0/45 -> 45/45 task lines, fact recall 0.157 -> 0.661

Browse files
Files changed (5) hide show
  1. README.md +105 -0
  2. adapter_config.json +46 -0
  3. adapter_model.safetensors +3 -0
  4. build_train.py +69 -0
  5. eval_compaction.py +133 -0
README.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ base_model: Lazarus-Ai/ReAligned-Qwen3.5-4B
3
+ library_name: peft
4
+ license: apache-2.0
5
+ tags:
6
+ - lora
7
+ - sft
8
+ - summarization
9
+ - context-compaction
10
+ - agents
11
+ - egirl
12
+ ---
13
+
14
+ # Compactor-Qwen3.5-4B
15
+
16
+ A context-compaction summariser for [egirl](https://github.com/Schneewolf-Labs/egirl): given a run
17
+ of agentic conversation about to be dropped, write the summary that lets the agent continue.
18
+
19
+ Meant to run as the **auxiliary model** — the operator keeps its slot and its context, and the
20
+ summary is produced by something small, deterministic, and trained for it.
21
+
22
+ ## The failure it fixes
23
+
24
+ egirl compacts by dropping middle messages and replacing them with a summary. When that summary
25
+ loses the task, the agent does not get confused — **it invents a new task**:
26
+
27
+ ```
28
+ Interior compaction: dropped 19 middle messages, kept head + 0 tail groups
29
+ Generated summary (396 chars) from 19 messages
30
+ ```
31
+
32
+ Sixteen web searches of real research compressed to 396 characters, after which the agent answered
33
+ a question about agent-harness architecture with *"Fresh project scaffolded at
34
+ ~/projects/fresh_project."* The request was gone from its context.
35
+
36
+ So the objective is not fluency. It is **preservation**: the task, the findings, the state.
37
+
38
+ ## Measured
39
+
40
+ 45 held-out compaction windows, from transcripts that contributed no training data. Deterministic
41
+ checks, no judge:
42
+
43
+ | | base | **+ Compactor** |
44
+ |---|---|---|
45
+ | has a `Task:` line | **0/45 (0%)** | **45/45 (100%)** |
46
+ | task overlap (word F1 vs reference) | 0.000 | **0.509** |
47
+ | **fact recall** (paths, identifiers, numbers, errors) | **0.157** | **0.661** |
48
+ | compression | 0.043 | **0.129** |
49
+ | mean output | 884 chars | 2,689 chars |
50
+
51
+ Reference summaries (GPT-5) compress to ~0.12×. The base compresses to 0.043× — it is not
52
+ summarising, it is discarding: **84% of the distinctive facts are gone**, and it never states the
53
+ task at all.
54
+
55
+ **Fact recall is the number to read.** A summary can be fluent, plausible, correctly shaped, and
56
+ still have the specifics sanded off — which is what a small model does by default, and what makes
57
+ the agent lose the thread three turns later.
58
+
59
+ ## Use
60
+
61
+ ```python
62
+ model = AutoModelForImageTextToText.from_pretrained(
63
+ "Lazarus-Ai/ReAligned-Qwen3.5-4B", dtype=torch.bfloat16, device_map="auto")
64
+ model = PeftModel.from_pretrained(model, "schneewolflabs/Compactor-Qwen3.5-4B")
65
+ ```
66
+
67
+ Render prompts with `enable_thinking=False` — training used the pre-closed think block, and the
68
+ model answers directly rather than deliberating. Serving with thinking open puts it in a state it
69
+ never saw.
70
+
71
+ In egirl, point `[local.auxiliary]` at a server running this and compaction routes to it
72
+ automatically.
73
+
74
+ ## Training
75
+
76
+ | | |
77
+ |---|---|
78
+ | base | `Lazarus-Ai/ReAligned-Qwen3.5-4B` |
79
+ | method | SFT, LoRA **r=64 α=128** dropout 0.05 |
80
+ | data | [egirl-compaction-SFT](https://huggingface.co/datasets/schneewolflabs/egirl-compaction-SFT) — 1,059 train / 45 val |
81
+ | tokens | 9.34M |
82
+ | schedule | 1 epoch, 133 steps, lr 1e-4 cosine |
83
+ | sequence | 16,384 — an 8192 cap would have dropped 55% of rows |
84
+ | hardware | one RTX A6000, 11h44m |
85
+
86
+ Train loss 0.738 → 0.584; held-out 0.754 → **0.561**, below train at every checkpoint. No
87
+ memorisation, despite r=64 on 1,059 samples — which the transcript-level split makes a meaningful
88
+ statement rather than an artefact.
89
+
90
+ **This architecture is expensive to train.** 265 s/step at 16k sequence, against 15.7 s/step for a
91
+ same-size Qwen3-VL under identical batch settings — the 24 linear-attention layers have no fused
92
+ kernel in transformers 5.3.0 and run a sequential scan whose cost grows with sequence length.
93
+ Inference is cheap; training is not.
94
+
95
+ ## Limits
96
+
97
+ - **One epoch, one checkpoint.** No epoch-2 comparison, which has mattered elsewhere in this
98
+ family: a sibling model won overall at epoch 2 while losing a specific behaviour it had at
99
+ epoch 1.
100
+ - **Targets are one model's judgement.** GPT-5 decided what mattered in each window. Where it
101
+ dropped something, this learned to drop it.
102
+ - **Task overlap is 0.509, not 0.9.** The task line is always present and usually about the right
103
+ thing; it is not word-for-word the reference. Treat it as "states the task" rather than "states
104
+ it identically".
105
+ - **One operator's transcripts**, 120 conversations of agentic coding. Other domains are untested.
adapter_config.json ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "alora_invocation_tokens": null,
3
+ "alpha_pattern": {},
4
+ "arrow_config": null,
5
+ "auto_mapping": null,
6
+ "base_model_name_or_path": "Lazarus-Ai/ReAligned-Qwen3.5-4B",
7
+ "bias": "none",
8
+ "corda_config": null,
9
+ "ensure_weight_tying": false,
10
+ "eva_config": null,
11
+ "exclude_modules": null,
12
+ "fan_in_fan_out": false,
13
+ "inference_mode": true,
14
+ "init_lora_weights": true,
15
+ "layer_replication": null,
16
+ "layers_pattern": null,
17
+ "layers_to_transform": null,
18
+ "loftq_config": {},
19
+ "lora_alpha": 128,
20
+ "lora_bias": false,
21
+ "lora_dropout": 0.05,
22
+ "megatron_config": null,
23
+ "megatron_core": "megatron.core",
24
+ "modules_to_save": null,
25
+ "peft_type": "LORA",
26
+ "peft_version": "0.18.1",
27
+ "qalora_group_size": 16,
28
+ "r": 64,
29
+ "rank_pattern": {},
30
+ "revision": null,
31
+ "target_modules": [
32
+ "down_proj",
33
+ "gate_proj",
34
+ "o_proj",
35
+ "up_proj",
36
+ "v_proj",
37
+ "k_proj",
38
+ "q_proj"
39
+ ],
40
+ "target_parameters": null,
41
+ "task_type": "CAUSAL_LM",
42
+ "trainable_token_indices": null,
43
+ "use_dora": false,
44
+ "use_qalora": false,
45
+ "use_rslora": false
46
+ }
adapter_model.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:195427fa658f09867e777ec9b97674b52122f240bd98e4b6fc3283c2ca9dece0
3
+ size 339776632
build_train.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render the compaction dataset for ReAligned-Qwen3.5-4B.
2
+
3
+ Two things this has to get right, both learned on sibling runs in this project.
4
+
5
+ **Thinking is closed in the prompt.** Qwen3.5's template opens `<think>` on every generation.
6
+ Rendered naively the target would begin `\n</think>\n\n`, teaching the model to emit a closing think
7
+ tag as the first thing in its answer — the broken hybrid-thinking gate. This template does honour
8
+ `enable_thinking=False`, which emits the empty block as part of the prompt, where the trainer masks
9
+ it. The summariser should answer directly anyway: egirl calls it for a summary, not a deliberation.
10
+
11
+ **Over-length rows are dropped, not truncated.** The trainer truncates prompts from the right,
12
+ which here removes the most recent messages in the window — the current state, the part a summary
13
+ most needs. A row that cannot fit is worth less than a row that teaches summarising from a
14
+ mutilated transcript.
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import statistics as st
20
+
21
+ from transformers import AutoTokenizer
22
+
23
+ MODEL = "Lazarus-Ai/ReAligned-Qwen3.5-4B"
24
+
25
+
26
+ def main() -> None:
27
+ ap = argparse.ArgumentParser()
28
+ ap.add_argument("--max-tokens", type=int, default=16384)
29
+ args = ap.parse_args()
30
+
31
+ tok = AutoTokenizer.from_pretrained(MODEL)
32
+
33
+ for split in ("train", "val"):
34
+ rows = [json.loads(line) for line in open(f"{split}.jsonl")]
35
+ out, lens, dropped = [], [], 0
36
+ for r in rows:
37
+ prompt = tok.apply_chat_template(
38
+ [{"role": "system", "content": r["system"]},
39
+ {"role": "user", "content": r["prompt"]}],
40
+ add_generation_prompt=True, enable_thinking=False, tokenize=False)
41
+ response = r["chosen"] + tok.eos_token
42
+ n = len(tok(prompt)["input_ids"]) + len(
43
+ tok(response, add_special_tokens=False)["input_ids"])
44
+ lens.append(n)
45
+ if n > args.max_tokens:
46
+ dropped += 1
47
+ continue
48
+ out.append({"prompt": prompt, "chosen": response,
49
+ "source_group": r["source_group"]})
50
+
51
+ with open(f"compaction_{split}.jsonl", "w") as f:
52
+ for o in out:
53
+ f.write(json.dumps(o) + "\n")
54
+
55
+ lens.sort()
56
+ print(f"{split}: {len(rows)} -> {len(out)} kept, {dropped} over {args.max_tokens}")
57
+ print(f" tokens mean {st.mean(lens):.0f} median {lens[len(lens)//2]} "
58
+ f"p95 {lens[int(.95*len(lens))]} max {max(lens)}")
59
+ for cap in (8192, 12288, 16384, 24576):
60
+ print(f" cap {cap:6d} would drop {sum(1 for x in lens if x > cap):4d}/{len(lens)}")
61
+ print(f" total tokens: {sum(x for x in lens if x <= args.max_tokens):,}")
62
+
63
+ sample = json.loads(open("compaction_train.jsonl").readline())
64
+ print("\nprompt tail:", repr(sample["prompt"][-70:]))
65
+ print("response head:", repr(sample["chosen"][:90]))
66
+
67
+
68
+ if __name__ == "__main__":
69
+ main()
eval_compaction.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Does the summary preserve what is needed to continue?
2
+
3
+ Loss is the wrong headline. The failure this tune exists to fix is specific: a summary that drops
4
+ the task, after which the agent invents a new one — sixteen searches of research compacted into 396
5
+ characters, answered with "fresh project scaffolded". So the metric is preservation, not fluency.
6
+
7
+ Four checks, all deterministic, no judge:
8
+
9
+ - **has_task** — is there a `Task:` line at all. The reference summaries all have one.
10
+ - **task_overlap** — word-level F1 between the generated task line and the reference's. Catches a
11
+ summary that states *a* task, just not this one.
12
+ - **fact_recall** — fraction of the reference's distinctive tokens (paths, identifiers, numbers,
13
+ errors) that survive into the generated summary. This is the one that catches a fluent summary
14
+ with the specifics sanded off, which is what a small model tends to produce.
15
+ - **compression** — length ratio. A "summary" that is 90% of the input has not compacted anything,
16
+ and one that is 2% has thrown the state away.
17
+
18
+ Run base and tuned through the identical path; the difference is the adapter.
19
+
20
+ Usage:
21
+ python3 eval_compaction.py --out base_eval.json
22
+ python3 eval_compaction.py --adapter out/adapter --out tuned_eval.json
23
+ python3 eval_compaction.py --compare base_eval.json tuned_eval.json
24
+ """
25
+
26
+ import argparse
27
+ import json
28
+ import re
29
+ import sys
30
+
31
+ MODEL = "Lazarus-Ai/ReAligned-Qwen3.5-4B"
32
+
33
+ # Tokens worth preserving: paths, dotted identifiers, CamelCase, numbers with units, errors.
34
+ DISTINCTIVE = re.compile(
35
+ r"(?:/[\w.\-/]{4,})"
36
+ r"|(?:\b\w+\.(?:ts|py|json|jsonl|toml|md|sh|jinja|gguf|safetensors)\b)"
37
+ r"|(?:\b[A-Z][a-z]+[A-Z]\w+\b)"
38
+ r"|(?:\b\d{2,}(?:\.\d+)?[kKmMgG]?\b)"
39
+ r"|(?:\b[A-Z][A-Z_]{3,}\b)"
40
+ )
41
+
42
+
43
+ def task_line(text: str) -> str:
44
+ m = re.search(r"^\s*task:\s*(.+)$", text, re.I | re.M)
45
+ return m.group(1).strip() if m else ""
46
+
47
+
48
+ def f1(a: str, b: str) -> float:
49
+ ta = {w.lower() for w in re.findall(r"\w+", a) if len(w) > 2}
50
+ tb = {w.lower() for w in re.findall(r"\w+", b) if len(w) > 2}
51
+ if not ta or not tb:
52
+ return 0.0
53
+ hit = len(ta & tb)
54
+ p, r = hit / len(ta), hit / len(tb)
55
+ return 0.0 if p + r == 0 else 2 * p * r / (p + r)
56
+
57
+
58
+ def score(generated: str, reference: str, prompt: str) -> dict:
59
+ ref_facts = set(DISTINCTIVE.findall(reference))
60
+ gen_facts = set(DISTINCTIVE.findall(generated))
61
+ return {
62
+ "has_task": bool(task_line(generated)),
63
+ "task_overlap": round(f1(task_line(generated), task_line(reference)), 3),
64
+ "fact_recall": round(len(ref_facts & gen_facts) / len(ref_facts), 3) if ref_facts else None,
65
+ "compression": round(len(generated) / max(len(prompt), 1), 4),
66
+ "chars": len(generated),
67
+ }
68
+
69
+
70
+ def run(args) -> None:
71
+ import torch
72
+ from transformers import AutoModelForImageTextToText, AutoTokenizer
73
+
74
+ tok = AutoTokenizer.from_pretrained(MODEL)
75
+ tok.padding_side = "left"
76
+ if tok.pad_token_id is None:
77
+ tok.pad_token = tok.eos_token
78
+
79
+ model = AutoModelForImageTextToText.from_pretrained(
80
+ MODEL, dtype=torch.bfloat16, device_map="cuda:0")
81
+ if args.adapter:
82
+ from peft import PeftModel
83
+ model = PeftModel.from_pretrained(model, args.adapter)
84
+ model.eval()
85
+
86
+ rows = [json.loads(l) for l in open(args.val)][: args.limit]
87
+ results = []
88
+ for i, r in enumerate(rows):
89
+ enc = tok(r["prompt"], return_tensors="pt").to(model.device)
90
+ with torch.no_grad():
91
+ out = model.generate(**enc, max_new_tokens=1200, do_sample=False,
92
+ pad_token_id=tok.pad_token_id)
93
+ gen = tok.decode(out[0][enc["input_ids"].shape[1]:], skip_special_tokens=True).strip()
94
+ s = score(gen, r["chosen"], r["prompt"])
95
+ s["text"] = gen[:1500]
96
+ results.append(s)
97
+ print(f"[{i+1}/{len(rows)}] task={s['has_task']} overlap={s['task_overlap']} "
98
+ f"facts={s['fact_recall']} chars={s['chars']}", flush=True)
99
+
100
+ summarise(results, args.adapter or "base")
101
+ json.dump({"adapter": args.adapter, "results": results}, open(args.out, "w"), indent=1)
102
+
103
+
104
+ def summarise(results: list, label: str) -> None:
105
+ n = len(results)
106
+ has = sum(r["has_task"] for r in results)
107
+ ov = [r["task_overlap"] for r in results]
108
+ fr = [r["fact_recall"] for r in results if r["fact_recall"] is not None]
109
+ comp = [r["compression"] for r in results]
110
+ print(f"\n--- {label} ({n} held-out windows) ---")
111
+ print(f" has Task: line {has}/{n} ({100*has/n:.0f}%)")
112
+ print(f" task overlap {sum(ov)/n:.3f} mean")
113
+ print(f" fact recall {sum(fr)/len(fr):.3f} mean" if fr else " fact recall n/a")
114
+ print(f" compression {sum(comp)/n:.3f} mean (reference ~0.12)")
115
+ print(f" mean chars {sum(r['chars'] for r in results)//n}")
116
+
117
+
118
+ def compare(a: str, b: str) -> int:
119
+ for path in (a, b):
120
+ d = json.load(open(path))
121
+ summarise(d["results"], d.get("adapter") or "base")
122
+ return 0
123
+
124
+
125
+ if __name__ == "__main__":
126
+ ap = argparse.ArgumentParser()
127
+ ap.add_argument("--adapter")
128
+ ap.add_argument("--val", default="compaction_val.jsonl")
129
+ ap.add_argument("--limit", type=int, default=45)
130
+ ap.add_argument("--out", default="compaction_eval.json")
131
+ ap.add_argument("--compare", nargs=2)
132
+ a = ap.parse_args()
133
+ sys.exit(compare(*a.compare) if a.compare else run(a))