File size: 23,621 Bytes
22de727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
"""Train one WebMix LoRA adapter on replayed browser-use rows (runs in the `webmix` env).

    python -m miniwebagent.train --adapter drag            # family adapter: CE on its rows, KL anchor on the rest
    python -m miniwebagent.train --adapter pooled          # one adapter on every family's rows, no anchor

Rows: data/webmix/replay/<task>/rows.jsonl (miniwebgen.replay; browser-use's own prompt + screenshot ->
the flash-mode JSON it answered). Each row is a family's by the macro segment it came from.

Loss (WebMix's row-disjoint KL anchor, method/hyperWeb/integrations/llamafactory-kl.patch
`disjoint_kl_loss`, re-implemented here because that patch targets a LLaMA-Factory fork): a row is
either a CE row (the adapter's own families: mean -log p(target token)) or a KL row (other families,
`--anchor-ratio` of them per CE row, redrawn every epoch: the k3 estimate exp(d) - d - 1 with
d = log p_ref - log p_theta on the target's action tokens (not the templated memory, see
`action_mask`), clamped to [-20, 8], weighted by --beta). The
reference is the same model with the adapter disabled, so no second copy is loaded.

Recipe (WebMix default): rank 16, alpha 32, dropout 0.05, attention + MLP projections of the language
model only (targets.py `attn_mlp`: q/k/v/o on the full-attention layers, gate/up/down on every
layer; no vision tower, no DeltaNet projections), lr 1e-4 cosine, AdamW. Batch 1 x grad-accum.
Only the target positions' logits are computed (`logits_to_keep`): a 248k-token vocabulary over a
~6.6k-token prompt would not fit otherwise.

Multi-GPU (granite, 4x A800, 2026-09-30): `torchrun --nproc_per_node N -m miniwebagent.train ...` gives each rank a
disjoint share of every epoch's rows and sums the LoRA gradients across ranks at each optimizer step (one
flat all-reduce; no DDP wrapper, so KL rows' adapter-off forwards need no special handling). --grad-accum
stays the GLOBAL batch in rows, so a run's recipe does not depend on the GPU count. Rows are encoded
(screenshot decode + tokenization) in background threads ahead of the GPU.
"""
from __future__ import annotations

from helpers.paths import REPO_ROOT
import argparse
import hashlib
import json
import os
import math
import random
import time
from pathlib import Path

from miniwebagent import REPLAY_DIR

ROOT = REPO_ROOT
ROWS_DIR = REPLAY_DIR
OUT_DIR = ROOT / "data" / "webmix" / "adapters"
MODEL = os.environ.get("WEBMIX_BASE_MODEL", "Qwen/Qwen3.5-4B")   # e.g. microsoft/Fara1.5-4B (RQ2 init swap), Qwen/Qwen3.5-9B

FAMILIES = ["Drag & gesture", "Discrete selection", "Text entry", "Form transaction", "Navigation",
            "Out-of-page I/O", "Reasoning base"]
# the one-5090 pilot (design doc "Pilot on one 5090"): three family adapters + one pooled over the rest
ADAPTERS = {
    "pooled": FAMILIES,
    "drag": ["Drag & gesture"],
    "io": ["Out-of-page I/O"],
    "select": ["Discrete selection"],
    "rest": ["Text entry", "Form transaction", "Navigation", "Reasoning base"],
    # reasoning steps and final answers (user, 2026-09-30): rows from rejection sampling on the train-site human
    # reasoning tasks (miniwebagent.evaluate --record-rows), KL-anchored on the synthetic families' rows
    "reasoning": ["Reasoning base"],
    # the planner (user, 2026-10-06: "lets try to train our planner"): its own rows by construction (miniwebgen.plan_data),
    # trained with --rows-dir on them and --beta 0 (no executor rows to anchor on)
    "planner": ["planner"],
}
TARGET_LEAVES = ["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]
TARGET_RE = r".*language_model.*\.(" + "|".join(TARGET_LEAVES) + r")$"


def save_adapter(model, out):
    """PEFT writes the training regex into adapter_config.json; servers (vLLM) expect module names.
    The weights only cover language-model modules, so the leaf-name list is equivalent."""
    model.save_pretrained(out, selected_adapters=["default"])     # not the frozen anchor copy (--init-adapter)
    cfg_path = Path(out) / "adapter_config.json"
    cfg = json.loads(cfg_path.read_text())
    cfg["target_modules"] = TARGET_LEAVES
    cfg_path.write_text(json.dumps(cfg, indent=2))


def is_val(task_id, frac=0.05):
    """A fixed ~5% of trajectories (by task) held out for validation loss."""
    return int(hashlib.md5(task_id.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF < frac


def load_rows(rows_dir=ROWS_DIR):
    rows = []
    for p in sorted(rows_dir.glob("*/rows.jsonl")):
        if p.parent.name.startswith("_"):
            continue
        try:
            parts = json.loads((p.parent / "episode.json").read_text()).get("parts")
        except (OSError, ValueError):
            parts = None
        for line in p.read_text().split("\n"):
            if line.strip():
                r = json.loads(line)
                if r.get("inject") or r.get("aug_prefix"):
                    continue          # a recovery episode's injected mistake, and its prefix (a copy of the plain replay)
                r["dir"] = str(p.parent)
                if parts:
                    r["parts"] = parts
                rows.append(r)
    return rows


def in_frac(r, frac):
    """Scaling runs (2026-10-01): keep a fixed `frac` of the generated trajectories, whole episodes at a time (a
    recovery variant follows its source, a chain needs all its parts); nested across fractions, separate from the
    validation split."""
    if frac >= 1:
        return True
    key = lambda t: int(hashlib.md5(f"frac:{t}".encode()).hexdigest()[:8], 16) / 0xFFFFFFFF   # noqa: E731
    return all(key(t) < frac for t in (r.get("parts") or [_base_task(r["task_id"])]))


def in_ids(r, ids):
    """--episode-ids (skill-mix ablation, 2026-10-02): keep a row when its trajectory is listed (a recovery variant follows
    its source, a chain needs all its parts)."""
    return ids is None or all(t in ids for t in (r.get("parts") or [_base_task(r["task_id"])]))


def row_is_val(r):
    """Held out when its trajectory is (a recovery variant follows its source, a chain any of its parts)."""
    return any(is_val(t) for t in (r.get("parts") or [_base_task(r["task_id"])]))


# ── examples ──────────────────────────────────────────────────────────────────

def to_hf(row):
    """OpenAI-format messages (image parts point at files next to rows.jsonl) -> (HF messages, images)."""
    from PIL import Image
    msgs, imgs = [], []
    for m in row["messages"]:
        c = m["content"]
        if isinstance(c, str):
            msgs.append({"role": m["role"], "content": [{"type": "text", "text": c}]})
            continue
        parts = []
        for x in c:
            if x.get("type") == "text":
                parts.append({"type": "text", "text": x["text"]})
            elif x.get("type") == "image_url":
                imgs.append(Image.open(Path(row["dir"]) / x["image_url"]["url"]).convert("RGB"))
                parts.append({"type": "image"})
        msgs.append({"role": m["role"], "content": parts})
    return msgs, imgs


def encode(processor, row):
    """-> model inputs + labels (-100 on the prompt) + n_target (supervised tokens incl. <|im_end|>)."""
    msgs, imgs = to_hf(row)
    prompt = processor.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
    full = prompt + row["target"] + "<|im_end|>"
    kw = {"images": imgs} if imgs else {}
    enc = processor(text=[full], return_tensors="pt", **kw)
    n_prompt = processor(text=[prompt], return_tensors="pt", **kw)["input_ids"].shape[1]
    labels = enc["input_ids"].clone()
    labels[:, :n_prompt] = -100
    enc["labels"] = labels
    enc["n_target"] = n = enc["input_ids"].shape[1] - n_prompt
    enc["action_mask"] = action_mask(processor.tokenizer, row["target"], n)
    return enc


def action_mask(tokenizer, target, n):
    """(n,) bool over the target tokens: True from the "action" field on (incl. <|im_end|>). The KL
    anchor applies there only (the memory is written by the base model itself since cycle 3, so it needs
    no anchor), and --ce-on action restricts the CE to it. All True when the
    target's standalone tokenization does not line up with its tokens in context."""
    import torch
    t = tokenizer(target + "<|im_end|>", add_special_tokens=False, return_offsets_mapping=True)
    at = target.find('"action"')
    if len(t["input_ids"]) != n or at < 0:
        return torch.ones(n, dtype=torch.bool)
    return torch.tensor([end > at for _, end in t["offset_mapping"]], dtype=torch.bool)


def target_logprobs(model, enc, device):
    """log p(target token) at each supervised position, computing logits only for those positions."""
    import torch
    n = enc["n_target"]
    inputs = {k: v.to(device) for k, v in enc.items() if k not in ("labels", "n_target", "action_mask")}
    out = model(**inputs, logits_to_keep=n + 1, use_cache=False)
    logits = out.logits[:, :-1, :].float()                    # positions predicting the n target tokens
    tgt = enc["labels"][:, -n:].to(device)
    return torch.log_softmax(logits, dim=-1).gather(-1, tgt.unsqueeze(-1)).squeeze(-1)   # (1, n)


def prefetched(processor, items, workers=4, ahead=8):
    """Yield (item, encode(row)) in order, encoding up to `ahead` rows in background threads."""
    from collections import deque
    from concurrent.futures import ThreadPoolExecutor
    with ThreadPoolExecutor(workers) as ex:
        q, it = deque(), iter(items)
        for x in it:
            q.append((x, ex.submit(encode, processor, x[0])))
            if len(q) >= ahead:
                break
        while q:
            x, fut = q.popleft()
            nxt = next(it, None)
            if nxt is not None:
                q.append((nxt, ex.submit(encode, processor, nxt[0])))
            yield x, fut.result()


def _dist():
    """-> (rank, world, local_rank); initialises NCCL under torchrun."""
    import os
    world = int(os.environ.get("WORLD_SIZE", "1"))
    if world == 1:
        return 0, 1, 0
    import torch
    import torch.distributed as dist
    local = int(os.environ["LOCAL_RANK"])
    torch.cuda.set_device(local)
    from datetime import timedelta
    dist.init_process_group("nccl", timeout=timedelta(hours=1))      # rank 0 evaluates while the others wait
    return dist.get_rank(), world, local


def _allreduce_grads(params, world):
    import torch
    import torch.distributed as dist
    grads = [p.grad if p.grad is not None else torch.zeros_like(p) for p in params]
    flat = torch.cat([g.reshape(-1) for g in grads])
    dist.all_reduce(flat)
    flat /= world
    i = 0
    for p, g in zip(params, grads):
        n = g.numel()
        p.grad = flat[i:i + n].view_as(p)
        i += n


# ── training ──────────────────────────────────────────────────────────────────

def main(argv=None):
    ap = argparse.ArgumentParser(prog="python -m miniwebagent.train")
    ap.add_argument("--adapter", required=True, choices=sorted(ADAPTERS))
    ap.add_argument("--epochs", type=float, default=3)
    ap.add_argument("--lr", type=float, default=1e-4)
    ap.add_argument("--rank", type=int, default=16)
    ap.add_argument("--alpha", type=int, default=32)
    ap.add_argument("--grad-accum", type=int, default=8)
    ap.add_argument("--beta", type=float, default=0.1, help="KL anchor weight (0 = no anchor rows)")
    ap.add_argument("--anchor-ratio", type=float, default=0.5, help="KL rows per CE row")
    ap.add_argument("--limit", type=int, default=0, help="cap CE rows (smoke tests)")
    ap.add_argument("--max-steps", type=int, default=0, help="stop after this many rows (smoke tests)")
    ap.add_argument("--out", default=None)
    ap.add_argument("--rows-dir", default=None, help="rows to train on (default: the executor replay, REPLAY_DIR)")
    ap.add_argument("--seed", type=int, default=0)
    ap.add_argument("--wandb-project", default="webmix-pilot", help="wandb project ('' = no wandb)")
    ap.add_argument("--ce-on", choices=["all", "action"], default="all",
                    help="CE on the whole target (memory + action) or on the action tokens only")
    ap.add_argument("--exclude-variants", default="", help="comma list of replay variants to leave out (recovery,chain)")
    ap.add_argument("--frac", type=float, default=1.0,
                    help="scaling: train on this fraction of the generated episodes (whole episodes, nested)")
    ap.add_argument("--episode-ids", default=None,
                    help="train only on the trajectories listed in this file (one task id per line; variants follow)")
    ap.add_argument("--init-adapter", default=None,
                    help="branch-then-specialize (2026-10-01): start from this adapter's weights (same rank/alpha) and "
                         "anchor the KL rows to it (a frozen copy) instead of the base model")
    ap.add_argument("--drop-done", action="store_true",
                    help="review round 1 control (2026-10-05): leave out every episode's final `done` step, so practice does "
                         "not teach the single agent to stop after one primitive (the `macro_done` before it stays)")
    ap.add_argument("--save-every", type=int, default=5000,
                    help="also save the adapter every N rows (out/rows_<n>; 0 = epoch boundaries only)")
    args = ap.parse_args(argv)

    import torch
    from peft import LoraConfig, get_peft_model
    from transformers import AutoModelForImageTextToText, AutoProcessor

    rank, world, local = _dist()
    main0 = rank == 0
    random.seed(args.seed)
    torch.manual_seed(args.seed)
    fams = set(ADAPTERS[args.adapter])
    rows = load_rows(Path(args.rows_dir)) if args.rows_dir else load_rows()
    if args.exclude_variants:
        drop = set(args.exclude_variants.split(","))
        rows = [r for r in rows if _variant(r["task_id"]) not in drop]
    if args.drop_done:
        n0 = len(rows)
        rows = [r for r in rows if r.get("op") != "done"]
        if main0:
            print(f"--drop-done: left out {n0 - len(rows)} final done steps", flush=True)
    ids = set(Path(args.episode_ids).read_text().split()) if args.episode_ids else None
    train = [r for r in rows if not row_is_val(r) and in_frac(r, args.frac) and in_ids(r, ids)]
    val = [r for r in rows if row_is_val(r)]
    ce_rows = [r for r in train if r["family"] in fams]
    kl_pool = [r for r in train if r["family"] not in fams] if args.beta > 0 else []
    if args.limit:
        random.shuffle(ce_rows)
        ce_rows = ce_rows[:args.limit]
    out = Path(args.out) if args.out else OUT_DIR / args.adapter
    out.mkdir(parents=True, exist_ok=True)
    if main0:
        print(f"adapter {args.adapter}: {len(ce_rows)} CE rows ({sorted(fams)}), anchor pool {len(kl_pool)}, "
              f"val {len(val)}, {world} GPU(s)", flush=True)

    device = f"cuda:{local}"
    processor = AutoProcessor.from_pretrained(MODEL)
    model = AutoModelForImageTextToText.from_pretrained(MODEL, dtype=torch.bfloat16, device_map=device,
                                                        attn_implementation="sdpa")
    model.config.use_cache = False
    model.gradient_checkpointing_enable(gradient_checkpointing_kwargs={"use_reentrant": False})
    model.enable_input_require_grads()
    model = get_peft_model(model, LoraConfig(r=args.rank, lora_alpha=args.alpha, lora_dropout=0.05, bias="none",
                                             target_modules=TARGET_RE, task_type="CAUSAL_LM"))
    anchor = None
    if args.init_adapter:
        # specialists trained from base on their family's rows alone lost the general habits (scrolling, finding,
        # navigating) pooled_v4 learned from every family; branching from pooled_v4 keeps them
        from peft import set_peft_model_state_dict
        from safetensors.torch import load_file
        sd = load_file(str(Path(args.init_adapter) / "adapter_model.safetensors"))
        res = set_peft_model_state_dict(model, sd)
        missing = [k for k in getattr(res, "missing_keys", []) if "lora_" in k]
        assert not missing, f"--init-adapter left {len(missing)} LoRA weights unset, e.g. {missing[:2]}"
        if args.beta > 0:
            model.load_adapter(args.init_adapter, adapter_name="anchor", is_trainable=False)
            model.set_adapter("default")
            anchor = "anchor"
        if main0:
            print(f"initialized from {args.init_adapter} ({len(sd)} tensors); KL anchor: "
                  f"{'that adapter (frozen)' if anchor else 'none'}", flush=True)
    if main0:
        model.print_trainable_parameters()

    n_kl = int(round(len(ce_rows) * args.anchor_ratio)) if kl_pool else 0
    per_epoch = (len(ce_rows) + n_kl) // world * world         # every rank takes the same number of rows
    total = int(math.ceil(per_epoch * args.epochs)) // world * world
    if args.max_steps:
        total = min(total, args.max_steps // world * world)
    accum = max(1, args.grad_accum // world)                   # rows per rank per optimizer step
    opt_steps = max(1, total // (accum * world))
    params = [p for p in model.parameters() if p.requires_grad]
    opt = torch.optim.AdamW(params, lr=args.lr, weight_decay=0.0)
    warm = max(1, int(0.03 * opt_steps))
    sched = torch.optim.lr_scheduler.LambdaLR(
        opt, lambda s: min(1.0, (s + 1) / warm) * 0.5 * (1 + math.cos(math.pi * min(1.0, s / opt_steps))))

    log = open(out / "train_log.jsonl", "a") if main0 else None
    val_fam = [r for r in val if r["family"] in fams]
    random.Random(1).shuffle(val_fam)            # a fixed sample across macros (the file order is alphabetical by macro)
    wb = None
    if args.wandb_project and main0:
        import wandb
        wb = wandb.init(project=args.wandb_project, name=f"{out.name}-{time.strftime('%m%d-%H%M')}", dir=str(out),
                        config={**vars(args), "families": sorted(fams), "base_model": MODEL, "ce_rows": len(ce_rows),
                                "anchor_pool": len(kl_pool), "anchor_rows_per_epoch": n_kl, "val_rows": len(val_fam),
                                "rows_total": total, "optimizer_steps": opt_steps, "gpus": world})
    model.train()
    done, t0, tokens, acc = 0, time.time(), 0, {"ce": [], "kl": []}     # done = rows over all ranks
    saved_at = 0
    epoch = 0
    while done < total:
        batch = [(r, "ce") for r in ce_rows] + [(r, "kl") for r in random.sample(kl_pool, min(n_kl, len(kl_pool)))]
        random.shuffle(batch)                    # the same order on every rank (same seed, same calls)
        batch = batch[:len(batch) // world * world][rank::world]
        batch = batch[:max(0, (total - done) // world)]
        k = 0
        for (r, kind), enc in prefetched(processor, batch):
            logp = target_logprobs(model, enc, device)
            if kind == "ce":
                if args.ce_on == "action":
                    m = enc["action_mask"].to(device).unsqueeze(0).float()
                    loss = -(logp * m).sum() / m.sum().clamp(min=1.0)
                else:
                    loss = -logp.mean()
            else:
                with torch.no_grad():
                    if anchor:                       # the initial adapter, frozen (branch-then-specialize)
                        model.set_adapter(anchor)
                        ref = target_logprobs(model, enc, device)
                        model.set_adapter("default")
                    else:
                        with model.disable_adapter():
                            ref = target_logprobs(model, enc, device)
                d = (ref - logp).clamp(-20.0, 8.0)
                m = enc["action_mask"].to(device).unsqueeze(0).float()
                loss = args.beta * ((torch.exp(d) - d - 1.0) * m).sum() / m.sum().clamp(min=1.0)
            (loss / accum).backward()
            acc[kind].append(loss.item() / (args.beta if kind == "kl" else 1.0))
            tokens += enc["input_ids"].shape[1] * world
            done += world
            k += 1
            if k % accum == 0 or done >= total:
                if world > 1:
                    _allreduce_grads(params, world)
                torch.nn.utils.clip_grad_norm_(params, 1.0)
                opt.step()
                sched.step()
                opt.zero_grad(set_to_none=True)
                # a mid-run checkpoint (a crash loses at most N rows; learning curves come for free)
                if main0 and args.save_every and done < total and done // args.save_every > saved_at // args.save_every:
                    save_adapter(model, out / f"rows_{done}")
                    saved_at = done
            if main0 and (done % (50 * world) == 0 or done >= total):
                el = time.time() - t0
                rec = {"rows": done, "of": total, "epoch": epoch,
                       "ce": sum(acc["ce"]) / max(1, len(acc["ce"])), "kl": sum(acc["kl"]) / max(1, len(acc["kl"])),
                       "lr": sched.get_last_lr()[0], "rows_per_s": done / el, "tokens_per_s": tokens / el,
                       "gpu_gb": torch.cuda.max_memory_allocated() / 2**30}
                log.write(json.dumps(rec) + "\n")
                log.flush()
                print(json.dumps(rec), flush=True)
                if wb:
                    wb.log(rec, step=done)
                acc = {"ce": [], "kl": []}
        epoch += 1
        if done < total:                         # a checkpoint + held-out CE at each epoch boundary
            if main0:
                save_adapter(model, out / f"epoch_{epoch}")
                v = evaluate(model, processor, val_fam[:100], device)
                print(json.dumps({"epoch": epoch, "val_ce": v}), flush=True)
                if wb:
                    wb.log({"val_ce": v, "epoch": epoch}, step=done)
            _barrier(world)
    if not main0:
        _barrier(world)
        return
    save_adapter(model, out)
    val_loss = evaluate(model, processor, val_fam[:200], device)
    meta = {"adapter": args.adapter, "families": sorted(fams), "base_model": MODEL, "ce_rows": len(ce_rows),
            "anchor_pool": len(kl_pool), "rows_trained": done, "val_ce": val_loss, "args": vars(args),
            "minutes": round((time.time() - t0) / 60, 1)}
    (out / "webmix_meta.json").write_text(json.dumps(meta, indent=1))
    print(json.dumps(meta), flush=True)
    if wb:
        wb.log({"val_ce": val_loss, "epoch": epoch}, step=done)
        wb.summary.update({"val_ce_final": val_loss, "minutes": meta["minutes"]})
        wb.finish()
    _barrier(world)


def _barrier(world):
    if world > 1:
        import torch.distributed as dist
        dist.barrier()


def _base_task(task_id):
    """A variant's held-out split follows its (first) source trajectory, so no trajectory leaks across it."""
    return task_id.split("~")[0]


def _variant(task_id):
    return "recovery" if task_id.endswith("~rec") else "chain" if task_id.startswith("chain-") else "plain"


def evaluate(model, processor, rows, device):
    """Mean target-token CE on held-out rows of the adapter's families (None without any)."""
    import torch
    if not rows:
        return None
    model.eval()
    tot = 0.0
    with torch.no_grad():
        for r in rows:
            tot += -target_logprobs(model, encode(processor, r), device).mean().item()
    model.train()
    return tot / len(rows)


if __name__ == "__main__":
    main()