kgrabko commited on
Commit
cd95fac
·
verified ·
1 Parent(s): 61ef25d

Create train_toolace_toolcalling_lora_ultra.py

Browse files
train_toolace_toolcalling_lora_ultra.py ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #%%writefile train_toolace_lora_ultra.py
2
+ # ==============================================================================
3
+ # JiRack Ultra ToolACE LoRA SFT + merge (single script, all four sizes)
4
+ # COPYRIGHT (c) 2026 Konstantin Vladimirovich Grabko.
5
+ #
6
+ # Adapted from the JiRackPrecision_8b ToolACE LoRA script. One file covers
7
+ # Ultra 1B / 7B / 14B / 32B -- set SIZE below, everything else follows from
8
+ # the SIZES table.
9
+ #
10
+ # What it does (unchanged from the 8B original):
11
+ # 1. Loads JiRackTransformer + your .pt checkpoint.
12
+ # 2. Freezes everything; injects LoRA (A/B low-rank pairs) into every
13
+ # nn.Linear except the LM head (out_features == vocab_size).
14
+ # 3. ALSO unfreezes the embedding rows of the JiRack special tokens --
15
+ # those rows are untrained padded slots right now; the model can't emit
16
+ # <|tool_call_start|> etc. until they're trained. A gradient hook zeroes
17
+ # grads for all other rows, so the base vocab embeddings stay untouched.
18
+ # 4. Trains with assistant-only loss masking, bf16 autocast, grad accum.
19
+ # 5. Saves the LoRA adapter alone + OPTIONAL merged checkpoint whose
20
+ # state_dict keys match the input .pt exactly.
21
+ #
22
+ # ============================ ULTRA-SPECIFIC CHANGES ==========================
23
+ # [U-A] BitLinear IS an nn.Linear subclass in every Ultra file, so isinstance()
24
+ # picks it up and LoRA wraps it -- which is what we want. But it also
25
+ # means the wrapped base runs BitLinear.forward, i.e. the quantization
26
+ # math, on every call. At LAMBDA=0.0 the result is mathematically
27
+ # identical to plain F.linear (lam=0 => w_effective=w, x_effective=x),
28
+ # but the BitLinear fast path only triggers in EVAL mode, so during
29
+ # training you pay for the quant math with no effect. Tolerable; if you
30
+ # want it gone, set LAMBDA=0.0 and patch BitLinear's fast-path condition
31
+ # to also fire while training.
32
+ # [U-B] FREEZE_8BIT IS DISABLED for Ultra. The 8B original swapped frozen
33
+ # nn.Linear for bnb.nn.Linear8bitLt -- on Ultra that would REPLACE your
34
+ # BitLinear modules with plain bnb layers, destroying the lambda_ buffers
35
+ # and the ternary path, and the merged state_dict keys would no longer
36
+ # match your checkpoint. Also, the original's merge_into_base() called
37
+ # bnb.functional.dequantize_4bit on an 8-bit layer, which is the wrong
38
+ # function anyway. If you need the memory, use adafactor + shorter
39
+ # MAX_LEN, or shard -- not this.
40
+ # [U-C] Tokenizer defaults to CMSManhattan/JiRackPrecisionTokenizer (the
41
+ # published JiRack tokenizer), with a hard assert that it FITS the padded
42
+ # matrix. Never resize: 151,779 < 151,936 (1B) and < 152,064 (7/14/32B),
43
+ # so a resize would SHRINK and corrupt the embedding matrix.
44
+ # [U-D] Per-size memory defaults in the SIZES table (MAX_LEN, GRAD_ACCUM).
45
+ # ==============================================================================
46
+
47
+ import json
48
+ import math
49
+ import os
50
+ import random
51
+ import sys
52
+ import time
53
+
54
+ import torch
55
+ import torch.nn as nn
56
+ from transformers import AutoTokenizer
57
+ from transformers.optimization import Adafactor
58
+
59
+ sys.path.append(os.getcwd())
60
+
61
+ # ========================= PICK YOUR SIZE =========================
62
+ SIZE = "1b" # "1b" | "7b" | "14b" | "32b"
63
+ # ==================================================================
64
+
65
+ # NOTE the module names -- they are NOT uniform in your repo:
66
+ # 1B -> JiRackTernaryUltra_1b.py
67
+ # 7B -> JiRackTernaryUltra7b.py <-- no underscore before "7b"!
68
+ # 14B -> JiRackTernaryUltra_14b.py
69
+ # 32B -> JiRackTernaryUltra_32b.py
70
+ # If you rename any of them, fix the "module" field below.
71
+ SIZES = {
72
+ "1b": {
73
+ "module": "JiRackTernaryUltra_1b",
74
+ "vocab": 151936,
75
+ "model_path": "/mnt/nfs_clientshare/JiRackUltra_1b/model.pt",
76
+ "adapter": "/mnt/nfs_clientshare/JiRackUltra_1b/toolace_lora_adapter.pt",
77
+ "merged": "/mnt/nfs_clientshare/JiRackUltra_1b/ultra1b_toolace.pt",
78
+ "max_len": 2048,
79
+ "grad_accum": 8,
80
+ },
81
+ "7b": {
82
+ "module": "JiRackTernaryUltra7b",
83
+ "vocab": 152064,
84
+ "model_path": "/mnt/nfs_clientshare/JiRackUltra_7b/model.pt",
85
+ "adapter": "/mnt/nfs_clientshare/JiRackUltra_7b/toolace_lora_adapter.pt",
86
+ "merged": "/mnt/nfs_clientshare/JiRackUltra_7b/ultra7b_toolace.pt",
87
+ "max_len": 2048,
88
+ "grad_accum": 16,
89
+ },
90
+ "14b": {
91
+ "module": "JiRackTernaryUltra_14b",
92
+ "vocab": 152064,
93
+ "model_path": "/mnt/nfs_clientshare/JiRackUltra_14b/model.pt",
94
+ "adapter": "/mnt/nfs_clientshare/JiRackUltra_14b/toolace_lora_adapter.pt",
95
+ "merged": "/mnt/nfs_clientshare/JiRackUltra_14b/ultra14b_toolace.pt",
96
+ "max_len": 1024, # [U-D] halve the context to fit
97
+ "grad_accum": 16,
98
+ },
99
+ "32b": {
100
+ "module": "JiRackTernaryUltra_32b",
101
+ "vocab": 152064,
102
+ "model_path": "/mnt/nfs_clientshare/JiRackUltra_32b/model.pt",
103
+ "adapter": "/mnt/nfs_clientshare/JiRackUltra_32b/toolace_lora_adapter.pt",
104
+ "merged": "/mnt/nfs_clientshare/JiRackUltra_32b/ultra32b_toolace.pt",
105
+ "max_len": 1024,
106
+ "grad_accum": 32,
107
+ },
108
+ }
109
+
110
+ if SIZE not in SIZES:
111
+ sys.exit(f"❌ SIZE must be one of {list(SIZES)}, got '{SIZE}'")
112
+ CFG = SIZES[SIZE]
113
+
114
+ _mod = __import__(CFG["module"], fromlist=["JiRackTransformer", "JiRackConfig"])
115
+ JiRackTransformer = _mod.JiRackTransformer
116
+ JiRackConfig = _mod.JiRackConfig
117
+
118
+ # ========================= EDIT THESE =========================
119
+ MODEL_PATH = CFG["model_path"]
120
+ TOKENIZER_DIR = "CMSManhattan/JiRackPrecisionTokenizer" # [U-C] HF repo or local dir
121
+ DATASET_PATH = "/mnt/nfs_clientshare/datasets/toolace_sft_jirack_precision.jsonl"
122
+ ADAPTER_OUT = CFG["adapter"]
123
+ MERGED_OUT = CFG["merged"]
124
+
125
+ # LoRA
126
+ LORA_R = 16
127
+ LORA_ALPHA = 32
128
+ LORA_DROPOUT = 0.05
129
+
130
+ # Training
131
+ EPOCHS = 2
132
+ LR = 2e-4 # LoRA params
133
+ EMBED_LR = 5e-5 # new-token embedding rows (gentler)
134
+ BATCH_SIZE = 1
135
+ GRAD_ACCUM = CFG["grad_accum"]
136
+ MAX_LEN = CFG["max_len"]
137
+ WARMUP_STEPS = 50
138
+ SEED = 42
139
+ LAMBDA = 0.0 # 0.0 = full-precision training (recommended:
140
+ # you're teaching tool-call FORMAT, not
141
+ # doing QAT -- run the ternarization QAT
142
+ # scripts separately, AFTER this merge)
143
+ SAVE_EVERY = 500 # optimizer steps between adapter checkpoints
144
+ MERGE_AT_END = True
145
+ OPTIMIZER = "adafactor" # "adamw" or "adafactor"
146
+ # adafactor: ~2 bytes/param optimizer state
147
+ # vs AdamW's ~8 -- matters at 14B/32B.
148
+ # [U-B] FREEZE_8BIT removed on purpose -- see header.
149
+ # ================================================================
150
+
151
+
152
+ # ------------------------------ LoRA machinery ------------------------------
153
+
154
+ class LoRALinear(nn.Module):
155
+ """Wraps a frozen nn.Linear (or BitLinear); adds trainable low-rank A/B."""
156
+
157
+ def __init__(self, base: nn.Linear, r: int, alpha: int, dropout: float):
158
+ super().__init__()
159
+ self.base = base
160
+ self.r = r
161
+ self.scale = alpha / r
162
+ self.lora_A = nn.Parameter(torch.zeros(r, base.in_features))
163
+ self.lora_B = nn.Parameter(torch.zeros(base.out_features, r))
164
+ nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5))
165
+ # B starts at zero -> identity behavior at step 0
166
+ self.dropout = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
167
+
168
+ def forward(self, x):
169
+ out = self.base(x) # [U-A] BitLinear.forward
170
+ lx = self.dropout(x).to(self.lora_A.dtype)
171
+ out = out + (lx @ self.lora_A.T @ self.lora_B.T) * self.scale
172
+ return out
173
+
174
+ @torch.no_grad()
175
+ def merge_into_base(self):
176
+ """Fold the LoRA delta into the base weight, in place. The base stays
177
+ the SAME module object (BitLinear stays BitLinear), so lambda_ buffers
178
+ and state_dict keys survive untouched."""
179
+ delta = (self.lora_B.float() @ self.lora_A.float()) * self.scale
180
+ self.base.weight.data += delta.to(self.base.weight.dtype)
181
+
182
+
183
+ def inject_lora(model, vocab_size):
184
+ """Replace every nn.Linear (except the vocab-sized head) with LoRALinear.
185
+ BitLinear subclasses nn.Linear, so the whole ternary backbone gets wrapped
186
+ -- intended. Embeddings are nn.Embedding, not nn.Linear, so they're skipped
187
+ here and handled separately by the row-mask logic."""
188
+ wrapped = []
189
+ for parent_name, parent in list(model.named_modules()):
190
+ for child_name, child in list(parent.named_children()):
191
+ if isinstance(child, LoRALinear):
192
+ continue
193
+ if isinstance(child, nn.Linear) and child.out_features != vocab_size:
194
+ setattr(parent, child_name,
195
+ LoRALinear(child, LORA_R, LORA_ALPHA, LORA_DROPOUT))
196
+ full = f"{parent_name}.{child_name}" if parent_name else child_name
197
+ wrapped.append(full)
198
+ return wrapped
199
+
200
+
201
+ def merge_and_unwrap(model):
202
+ """Fold LoRA into base weights and restore the original modules, so
203
+ state_dict() keys match the original checkpoint exactly."""
204
+ for parent_name, parent in list(model.named_modules()):
205
+ for child_name, child in list(parent.named_children()):
206
+ if isinstance(child, LoRALinear):
207
+ child.merge_into_base()
208
+ setattr(parent, child_name, child.base)
209
+
210
+
211
+ # ------------------------------ Dataset ------------------------------
212
+
213
+ def load_dataset(path):
214
+ convs = []
215
+ with open(path) as f:
216
+ for line in f:
217
+ line = line.strip()
218
+ if not line:
219
+ continue
220
+ obj = json.loads(line)
221
+ msgs = obj.get("messages", obj)
222
+ if isinstance(msgs, list) and any(m.get("role") == "assistant" for m in msgs):
223
+ convs.append(msgs)
224
+ return convs
225
+
226
+
227
+ def build_example(tokenizer, messages, max_len):
228
+ """Tokenize a conversation with assistant-only labels.
229
+ Incremental templating: token span of message i = template(msgs[:i+1]) minus
230
+ template(msgs[:i]). Labels = ids inside assistant spans, else -100."""
231
+ ids, labels = [], []
232
+ prev = []
233
+ prev_len = 0
234
+ for m in messages:
235
+ prev.append(m)
236
+ cur = tokenizer.apply_chat_template(prev, tokenize=True,
237
+ add_generation_prompt=False)
238
+ span = cur[prev_len:]
239
+ if m["role"] == "assistant":
240
+ labels.extend(span)
241
+ else:
242
+ labels.extend([-100] * len(span))
243
+ ids = cur
244
+ prev_len = len(cur)
245
+ if len(ids) >= max_len:
246
+ break
247
+ ids = ids[:max_len]
248
+ labels = labels[:max_len]
249
+ if all(l == -100 for l in labels):
250
+ return None
251
+ return torch.tensor(ids), torch.tensor(labels)
252
+
253
+
254
+ # ------------------------------ Training ------------------------------
255
+
256
+ def main():
257
+ random.seed(SEED)
258
+ torch.manual_seed(SEED)
259
+ device = "cuda" if torch.cuda.is_available() else "cpu"
260
+ print(f"🚀 JiRack Ultra {SIZE.upper()} ToolACE LoRA | Device: {device.upper()}")
261
+ print(f"⚙️ module={CFG['module']} optimizer={OPTIMIZER} "
262
+ f"MAX_LEN={MAX_LEN} GRAD_ACCUM={GRAD_ACCUM}")
263
+
264
+ tokenizer = AutoTokenizer.from_pretrained(TOKENIZER_DIR)
265
+
266
+ # --- model ---
267
+ config = JiRackConfig()
268
+ # [U-C] the config's vocab must match what this script expects for this size
269
+ assert config.vocab_size == CFG["vocab"], (
270
+ f"{CFG['module']}.JiRackConfig has vocab_size={config.vocab_size} but "
271
+ f"SIZE='{SIZE}' expects {CFG['vocab']} -- wrong module for this size?"
272
+ )
273
+ # [U-C] tokenizer must FIT the padded matrix; never resize
274
+ assert len(tokenizer) <= config.vocab_size, (
275
+ f"tokenizer ({len(tokenizer)}) > padded matrix ({config.vocab_size}) — "
276
+ f"do NOT resize_token_embeddings, fix the tokenizer instead"
277
+ )
278
+ print(f"✅ Tokenizer fits: {len(tokenizer)} <= {config.vocab_size}")
279
+
280
+ model = JiRackTransformer(config, use_checkpoint=True) # activation ckpt on
281
+ print(f"📥 Loading {MODEL_PATH} ...")
282
+ ckpt = torch.load(MODEL_PATH, map_location="cpu", weights_only=False)
283
+ sd = ckpt["model"] if isinstance(ckpt, dict) and "model" in ckpt else ckpt
284
+ missing, unexpected = model.load_state_dict(sd, strict=False)
285
+ real_missing = [k for k in missing if not k.endswith("lambda_")]
286
+ if real_missing:
287
+ print(f"⚠️ Missing keys: {real_missing[:10]}")
288
+ if unexpected:
289
+ print(f"⚠️ Unexpected keys: {list(unexpected)[:10]}")
290
+ model = model.to(dtype=torch.bfloat16, device=device)
291
+ model.set_lambda(LAMBDA)
292
+
293
+ # find embedding module + vocab size
294
+ embed = None
295
+ for mod in model.modules():
296
+ if isinstance(mod, nn.Embedding):
297
+ embed = mod
298
+ break
299
+ if embed is None:
300
+ sys.exit("❌ No nn.Embedding found in model")
301
+ vocab_rows = embed.weight.shape[0]
302
+ print(f" embedding rows: {vocab_rows}")
303
+
304
+ # --- find the LM head BEFORE wrapping (after injection it'd be hidden) ---
305
+ head = None
306
+ for mod in model.modules():
307
+ if isinstance(mod, nn.Linear) and mod.out_features == vocab_rows:
308
+ head = mod
309
+ break
310
+
311
+ # --- freeze all, inject LoRA ---
312
+ for p in model.parameters():
313
+ p.requires_grad = False
314
+ wrapped = inject_lora(model, vocab_rows)
315
+ model = model.to(device)
316
+ print(f"🧩 LoRA injected into {len(wrapped)} Linear layers "
317
+ f"(r={LORA_R}, alpha={LORA_ALPHA})")
318
+
319
+ lora_params = [p for n, p in model.named_parameters() if "lora_" in n]
320
+ for p in lora_params:
321
+ p.requires_grad = True
322
+
323
+ # --- unfreeze ONLY the JiRack special-token embedding rows ---
324
+ special_ids = sorted(set(tokenizer.additional_special_tokens_ids or []))
325
+ special_ids = [i for i in special_ids if i < vocab_rows]
326
+ if not special_ids:
327
+ print("⚠️ No additional_special_tokens found in the tokenizer -- "
328
+ "training LoRA only, no embedding rows. If you expected the "
329
+ "JiRack tool-call/robotics tags here, check the tokenizer repo.")
330
+ embed.weight.requires_grad = True
331
+ row_mask = torch.zeros(vocab_rows, 1, device=device)
332
+ for i in special_ids:
333
+ row_mask[i] = 1.0
334
+ embed.weight.register_hook(lambda g: g * row_mask.to(g.dtype))
335
+ if special_ids:
336
+ print(f"🎯 Training embedding rows for {len(special_ids)} special tokens "
337
+ f"(ids {special_ids[0]}..{special_ids[-1]}), base vocab frozen "
338
+ f"via grad mask.")
339
+
340
+ # untied lm_head: train the same rows there too (the model can't EMIT a
341
+ # token whose output row is noise, even with good input embeddings)
342
+ if head is not None and head.weight is not embed.weight:
343
+ head.weight.requires_grad = True
344
+ head.weight.register_hook(lambda g: g * row_mask.to(g.dtype))
345
+ print("🎯 LM head is untied -- training the same rows there as well.")
346
+ elif head is None:
347
+ print("⚠️ No vocab-sized Linear found -- lm_head not trained.")
348
+
349
+ n_train = sum(p.numel() for p in model.parameters() if p.requires_grad)
350
+ print(f" trainable params (incl. masked embeds): {n_train/1e6:.1f}M")
351
+
352
+ # --- data ---
353
+ convs = load_dataset(DATASET_PATH)
354
+ print(f"📚 {len(convs)} conversations loaded from {DATASET_PATH}")
355
+ random.shuffle(convs)
356
+
357
+ # --- optimizer ---
358
+ groups = [{"params": lora_params, "lr": LR}]
359
+ embed_params = [embed.weight]
360
+ if head is not None and head.weight is not embed.weight:
361
+ embed_params.append(head.weight)
362
+ groups.append({"params": embed_params, "lr": EMBED_LR})
363
+
364
+ if OPTIMIZER == "adafactor":
365
+ # relative_step=False + explicit per-group lr so our own cosine
366
+ # schedule (LambdaLR below) still controls the learning rate.
367
+ optim = Adafactor(groups, scale_parameter=False, relative_step=False,
368
+ warmup_init=False, weight_decay=0.0)
369
+ print("⚙️ Optimizer: Adafactor (relative_step=False, no momentum buffer)")
370
+ elif OPTIMIZER == "adamw":
371
+ optim = torch.optim.AdamW(groups, weight_decay=0.0)
372
+ print("⚙️ Optimizer: AdamW")
373
+ else:
374
+ sys.exit(f"❌ Unknown OPTIMIZER '{OPTIMIZER}' -- use 'adamw' or 'adafactor'")
375
+
376
+ total_steps = max(1, (len(convs) * EPOCHS) // (BATCH_SIZE * GRAD_ACCUM))
377
+
378
+ def lr_lambda(step):
379
+ if step < WARMUP_STEPS:
380
+ return step / max(1, WARMUP_STEPS)
381
+ prog = (step - WARMUP_STEPS) / max(1, total_steps - WARMUP_STEPS)
382
+ return 0.5 * (1.0 + math.cos(math.pi * min(1.0, prog)))
383
+ sched = torch.optim.lr_scheduler.LambdaLR(optim, lr_lambda)
384
+
385
+ loss_fn = nn.CrossEntropyLoss(ignore_index=-100)
386
+
387
+ def save_adapter(path):
388
+ state = {n: p.detach().cpu() for n, p in model.named_parameters()
389
+ if "lora_" in n}
390
+ state["__special_ids__"] = torch.tensor(special_ids)
391
+ if special_ids:
392
+ state["__embed_rows__"] = embed.weight.detach()[special_ids].cpu()
393
+ if head is not None and head.weight is not embed.weight:
394
+ state["__head_rows__"] = head.weight.detach()[special_ids].cpu()
395
+ torch.save({"size": SIZE, "lora_r": LORA_R, "lora_alpha": LORA_ALPHA,
396
+ "state": state}, path)
397
+ print(f"💾 Adapter saved: {path}")
398
+
399
+ # --- loop ---
400
+ model.train()
401
+ step, micro, running = 0, 0, 0.0
402
+ t0 = time.time()
403
+ for epoch in range(EPOCHS):
404
+ for conv in convs:
405
+ ex = build_example(tokenizer, conv, MAX_LEN)
406
+ if ex is None:
407
+ continue
408
+ ids, labels = ex
409
+ ids = ids.unsqueeze(0).to(device)
410
+ labels = labels.unsqueeze(0).to(device)
411
+
412
+ with torch.autocast(device_type=("cuda" if device == "cuda" else "cpu"),
413
+ dtype=torch.bfloat16):
414
+ logits = model(ids)
415
+ loss = loss_fn(
416
+ logits[:, :-1, :].reshape(-1, logits.size(-1)).float(),
417
+ labels[:, 1:].reshape(-1))
418
+
419
+ if torch.isnan(loss) or torch.isinf(loss):
420
+ print(f"⚠️ NaN/Inf loss at micro-step {micro} — example skipped")
421
+ optim.zero_grad(set_to_none=True)
422
+ micro += 1
423
+ continue
424
+
425
+ (loss / GRAD_ACCUM).backward()
426
+ running += loss.item()
427
+ micro += 1
428
+
429
+ if micro % GRAD_ACCUM == 0:
430
+ torch.nn.utils.clip_grad_norm_(
431
+ [p for p in model.parameters() if p.requires_grad], 1.0)
432
+ optim.step()
433
+ sched.step()
434
+ optim.zero_grad(set_to_none=True)
435
+ step += 1
436
+ if step % 10 == 0:
437
+ avg = running / (10 * GRAD_ACCUM)
438
+ running = 0.0
439
+ el = time.time() - t0
440
+ print(f"epoch {epoch+1} step {step}/{total_steps} "
441
+ f"loss {avg:.4f} lr {sched.get_last_lr()[0]:.2e} "
442
+ f"[{el/60:.1f} min]")
443
+ if step % SAVE_EVERY == 0:
444
+ save_adapter(ADAPTER_OUT)
445
+
446
+ save_adapter(ADAPTER_OUT)
447
+
448
+ # --- merge ---
449
+ if MERGE_AT_END:
450
+ print("🔀 Merging LoRA into base weights ...")
451
+ model.eval()
452
+ merge_and_unwrap(model)
453
+ merged_sd = {k: v.detach().cpu() for k, v in model.state_dict().items()}
454
+ # drop lambda_ buffers if the original checkpoint didn't carry them
455
+ orig_keys = set(sd.keys())
456
+ merged_sd = {k: v for k, v in merged_sd.items()
457
+ if k in orig_keys or not k.endswith("lambda_")}
458
+ extra = set(merged_sd.keys()) - orig_keys
459
+ missing2 = orig_keys - set(merged_sd.keys())
460
+ if extra:
461
+ print(f"⚠️ Keys not in original ckpt (kept): {list(extra)[:8]}")
462
+ if missing2:
463
+ print(f"⚠️ Original keys absent in merged (check!): {list(missing2)[:8]}")
464
+ torch.save(merged_sd, MERGED_OUT)
465
+ print(f"✅ Merged checkpoint saved: {MERGED_OUT}")
466
+ print(f" Next: point your chat script at it, verify tool tags are "
467
+ f"emitted, THEN run the ternarization QAT script for {SIZE}.")
468
+
469
+
470
+ if __name__ == "__main__":
471
+ main()