File size: 31,869 Bytes
d4d7197
 
 
 
 
4a62dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4d7197
 
 
6010c50
 
 
 
 
 
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a62dee
 
 
 
 
 
 
 
 
 
 
 
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a62dee
 
 
 
 
d4d7197
 
 
 
 
 
 
 
6010c50
d4d7197
 
 
 
 
 
4a62dee
d4d7197
6010c50
 
 
 
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a62dee
d4d7197
 
4a62dee
 
 
 
 
 
d4d7197
 
4a62dee
 
 
 
d4d7197
4a62dee
 
 
 
 
 
 
3c0eecd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
 
 
 
 
 
 
 
 
 
4a62dee
 
 
 
3c0eecd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a62dee
 
 
 
 
 
 
 
 
 
 
 
 
3c0eecd
 
 
 
 
 
 
 
 
 
 
 
d4d7197
 
4a62dee
 
3c0eecd
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c0eecd
 
 
 
 
d4d7197
 
 
4a62dee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4d7197
6010c50
 
 
d4d7197
6010c50
 
d4d7197
 
6010c50
 
 
 
 
 
 
 
 
d4d7197
 
6010c50
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4a62dee
 
 
 
3c0eecd
 
 
 
 
 
 
 
 
 
6010c50
d4d7197
 
6010c50
4a62dee
6010c50
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
 
 
d4d7197
6010c50
 
 
 
d4d7197
6010c50
d4d7197
4a62dee
 
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6010c50
 
 
d4d7197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
"""Hugging Face Space training core: SFT / DPO / KTO, one step at a time,
resumable.

Design goals
------------
* **One bucket + one model per deployment (env, not per request).** The
  ``BUCKET_ID`` and ``MODEL_ID`` environment variables configure the whole
  Space β€” they are *not* re-entered on every request. Loading the base model
  once and keeping it resident avoids re-downloading / re-loading it on every
  call.
* **Model kept resident in RAM/VRAM.** The base model is loaded once and kept
  in a module-level cache across calls (the expensive part β€” download + load β€”
  happens a single time). The LoRA adapter weights are tiny and are reset from
  the resumed checkpoint on each call, so reuse is safe: the frozen base
  weights never change, only the adapter + optimizer state do.
* **GPU-when-available, CPU otherwise.** The device is chosen from
  ``torch.cuda.is_available()``. On a ZeroGPU Space ``@spaces.GPU`` attaches an
  A10G for the call (so we train on GPU); off ZeroGPU the decorator is a no-op
  and we train on CPU. Because ZeroGPU's GPU is *transient* (it detaches
  between calls), the resident cache is only used when the device is
  persistent (a dedicated-GPU or CPU Space); on ZeroGPU the model is loaded per
  call from the local HF cache instead.
* **No data persistence.** The uploaded "trace" is parsed into an in-memory
  ``datasets`` object for the duration of the call and discarded afterwards.
  Only weights + optimizer/scheduler state are ever written to the bucket.
* **Dynamic gradient accumulation.** The number of optimizer steps a call runs
  is derived from the number of uploaded examples and the deployment's
  gradient-accumulation size ``G`` (``GRAD_ACCUM`` env):
  ``steps = ceil(num_examples / G)`` (SFT/DPO). A single trace -> 1 step; 10
  traces with ``G=4`` -> 3 steps (4 + 4 + 2). The caller no longer chooses a
  fixed "steps per call" β€” it just uploads the trace.
* **Incremental, resumable training.** Each call advances training by the
  dynamic number of steps above. The HF ``Trainer`` checkpoint
  (model/adapter weights + ``optimizer.pt`` + ``scheduler.pt`` + ``rng`` +
  ``trainer_state.json``) is persisted to an HF Storage Bucket, so the next
  call resumes exactly where it left off β€” the optimizer does **not** restart
  from scratch. Shut the Space down, come back later, keep training.

Method data schemas
-------------------
SFT  (``trl.SFTTrainer``) β€” prompt/completion or plain text:
    {"messages": [{"role": "user", "content": "..."},
                  {"role": "assistant", "content": "..."}]}
    or  {"text": "a single string"}

DPO  (``trl.DPOTrainer``) β€” chosen/rejected pairs:
    {"prompt": "...", "chosen": "...", "rejected": "..."}
    (messages-format ``prompt``/``chosen``/``rejected`` also accepted)

KTO  (``trl.KTOTrainer``) β€” binary desirable/undesirable feedback:
    {"prompt": "...", "completion": "...", "desirable": true|false}
"""

from __future__ import annotations

import json
import os
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import torch
from datasets import Dataset
from huggingface_hub import snapshot_download
from peft import LoraConfig, get_peft_model
from transformers import AutoModelForCausalLM, AutoTokenizer

# `spaces` lets us detect whether we are running on a ZeroGPU Space. On
# ZeroGPU the GPU is *transient* (attached only for the duration of a
# ``@spaces.GPU`` call), so a model cannot be kept resident across calls β€” it
# would be freed and any cached reference would dangle. We therefore only keep
# the model resident when the device is *persistent* (a dedicated-GPU or CPU
# Space). The import is optional; the platform ships its own `spaces` and, on
# any non-ZeroGPU Space, `spaces.Config.zero_gpu` is simply False.
try:
    from spaces.config import Config as _SpacesConfig

    _ZERO_GPU = bool(getattr(_SpacesConfig, "zero_gpu", False))
except Exception:  # pragma: no cover
    _ZERO_GPU = False

from store import (
    checkpoint_paths,
    pull_checkpoint,
    push_checkpoint,
    save_state_index,
)

HERE = Path(__file__).resolve().parent
LOCAL_CACHE = HERE / ".cache"

# Small, CPU-fine base model used for defaults / smoke tests.
DEFAULT_MODEL_ID = "HuggingFaceTB/SmolLM2-135M"

# Injected when a base model's tokenizer has no chat template, so SFT with
# chat-format ("messages") traces works on *any* base model, not just -Instruct
# ones. A simple, valid template transformers can apply.
DEFAULT_CHAT_TEMPLATE = (
    "{%- for message in messages -%}\n"
    "{% if message['role'] == 'user' %}User: {{ message['content'] }}\n"
    "{% elif message['role'] == 'assistant' %}Assistant: {{ message['content'] }}\n"
    "{% elif message['role'] == 'system' %}System: {{ message['content'] }}\n"
    "{% endif %}{%- endfor -%}"
)


# --------------------------------------------------------------------------- #
#  Configuration
# --------------------------------------------------------------------------- #
@dataclass
class TrainConfig:
    # One model + one bucket per deployment. These come from the Space's
    # environment variables (MODEL_ID, BUCKET_ID) and are NOT re-entered per
    # request. `resolve_*` fills them from the env when left empty, so the
    # whole deployment is configured once.
    model_id: str = ""  # e.g. "HuggingFaceTB/SmolLM2-135M" β€” from MODEL_ID env
    method: str = "sft"  # sft | dpo | kto
    # LoRA β€” keeps CPU training tractable on a ZeroGPU container.
    lora_r: int = 8
    lora_alpha: int = 16
    lora_dropout: float = 0.0
    target_modules: tuple = ("q_proj", "k_proj", "v_proj", "o_proj")
    # Optimizer / training.
    learning_rate: float = 1e-4
    # Constant LR keeps "one step at a time" stable across resumable calls;
    # a decaying schedule would require a fixed horizon and is not the goal here.
    lr_scheduler_type: str = "constant"
    optim: str = "adamw_torch"
    weight_decay: float = 0.0
    max_length: int = 256
    # Bucket + run.
    bucket_id: str = ""  # e.g. "username/my-bucket" β€” from BUCKET_ID env
    resume: bool = True  # resume from latest checkpoint if one exists
    # Gradient accumulation: how many micro-batches to accumulate per optimizer
    # step. The number of optimizer steps this call runs is derived from the
    # number of uploaded examples:  steps_this_call = ceil(num_examples /
    # grad_accum).  10 examples with GRAD_ACCUM=4  ->  3 steps (4+4+2).
    # 1 example  ->  1 step (single trace, single step).
    grad_accum: int = 4
    save_total_limit: int = 3  # keep only the N most-recent checkpoints


# --------------------------------------------------------------------------- #
#  Result
# --------------------------------------------------------------------------- #
@dataclass
class TrainResult:
    method: str
    model_id: str
    global_step: int
    loss: float | None = None
    checkpoint_id: str = ""
    resumed_from: str | None = None
    num_examples: int = 0
    grad_accum: int = 1
    steps_run: int = 0  # optimizer steps this call advanced
    notes: list = field(default_factory=list)
    elapsed: float = 0.0

    def as_dict(self) -> dict:
        return {
            "method": self.method,
            "model_id": self.model_id,
            "global_step": self.global_step,
            "loss": round(self.loss, 6) if self.loss is not None else None,
            "checkpoint_id": self.checkpoint_id,
            "resumed_from": self.resumed_from,
            "num_examples": self.num_examples,
            "grad_accum": self.grad_accum,
            "steps_run": self.steps_run,
            "notes": self.notes,
            "elapsed_sec": round(self.elapsed, 2),
        }


# --------------------------------------------------------------------------- #
#  Trace parsing  (the ONLY place the uploaded data is touched)
# --------------------------------------------------------------------------- #
def parse_trace(rows: list[dict], method: str) -> Dataset:
    """Turn raw uploaded rows into a ``datasets.Dataset`` for the method.

    Rows come in as a list of dicts (one JSON object per line, or a small
    JSON array). Anything that doesn't parse is dropped (logged, not fatal).
    """
    m = method.lower()
    if m == "sft":
        return _parse_sft(rows)
    if m == "dpo":
        return _parse_dpo(rows)
    if m == "kto":
        return _parse_kto(rows)
    raise ValueError(f"Unknown method: {method!r}")


def _parse_sft(rows: list[dict]) -> Dataset:
    # Prefer chat format; fall back to a single "text" field.
    conv = []
    for r in rows:
        if "messages" in r and isinstance(r["messages"], list):
            conv.append({"messages": r["messages"]})
    if conv:
        return Dataset.from_list(conv)
    texts = []
    for r in rows:
        if "text" in r:
            texts.append({"text": r["text"]})
        elif "completion" in r and "prompt" in r:
            # prompt/completion -> single text for SFT
            texts.append({"text": f"{r['prompt']}{r['completion']}"})
    if texts:
        return Dataset.from_list(texts)
    raise ValueError("SFT trace needs rows with 'messages' (chat) or 'text'.")


def _parse_dpo(rows: list[dict]) -> Dataset:
    out = []
    for r in rows:
        if "chosen" in r and "rejected" in r:
            out.append(
                {
                    "prompt": r.get("prompt", ""),
                    "chosen": r["chosen"],
                    "rejected": r["rejected"],
                }
            )
    if not out:
        raise ValueError("DPO trace needs rows with 'chosen' and 'rejected'.")
    return Dataset.from_list(out)


def _parse_kto(rows: list[dict]) -> Dataset:
    # trl's KTOTrainer expects a boolean ``label`` column (True = desirable);
    # the trace uses the friendlier ``desirable`` name, so map it through.
    out = []
    for r in rows:
        completion = r.get("completion")
        desirable = r.get("desirable", r.get("label"))
        if completion is not None and desirable is not None:
            out.append(
                {
                    "prompt": r.get("prompt", ""),
                    "completion": completion,
                    "label": bool(desirable),
                }
            )
    if not out:
        raise ValueError("KTO trace needs rows with 'completion' and 'desirable'.")
    return Dataset.from_list(out)


# --------------------------------------------------------------------------- #
#  Env + resident model cache
# --------------------------------------------------------------------------- #
def _resolve_token() -> str | None:
    # On a Space the token is set as a secret (HF_TOKEN / HUGGING_FACE_HUB_TOKEN).
    return (
        os.environ.get("HF_TOKEN")
        or os.environ.get("HUGGING_FACE_HUB_TOKEN")
        or os.environ.get("HF_HUB_TOKEN")
    )


def resolve_model_id(cfg: TrainConfig) -> str:
    """The model for this deployment, from MODEL_ID env (or the passed
    config). One model per deployment β€” not re-chosen per request."""
    return (cfg.model_id or os.environ.get("MODEL_ID") or DEFAULT_MODEL_ID).strip()


def resolve_bucket_id(cfg: TrainConfig) -> str:
    """The bucket for this deployment, from BUCKET_ID env (or the passed
    config). One bucket per deployment β€” not re-chosen per request."""
    return (cfg.bucket_id or os.environ.get("BUCKET_ID") or "").strip()


def resolve_lora_r(cfg: TrainConfig) -> int:
    """LoRA rank for this deployment, from LORA_R env (or the config default)."""
    raw = os.environ.get("LORA_R")
    return int(raw) if raw else cfg.lora_r


def resolve_lora_alpha(cfg: TrainConfig) -> int:
    """LoRA alpha for this deployment, from LORA_ALPHA env (or the config
    default)."""
    raw = os.environ.get("LORA_ALPHA")
    return int(raw) if raw else cfg.lora_alpha


def resolve_lora_dropout(cfg: TrainConfig) -> float:
    """LoRA dropout for this deployment, from LORA_DROPOUT env (or the config
    default)."""
    raw = os.environ.get("LORA_DROPOUT")
    return float(raw) if raw else cfg.lora_dropout


def resolve_target_modules(cfg: TrainConfig):
    """Target modules for this deployment, from LORA_TARGET_MODULES env
    (comma-separated, e.g. ``q_proj,k_proj,v_proj,o_proj``) or the config
    default tuple."""
    raw = os.environ.get("LORA_TARGET_MODULES")
    if raw:
        return tuple(p.strip() for p in raw.split(",") if p.strip())
    return tuple(cfg.target_modules)


def resolve_learning_rate(cfg: TrainConfig) -> float:
    """Learning rate for this deployment, from LEARNING_RATE env (or the config
    default)."""
    raw = os.environ.get("LEARNING_RATE")
    return float(raw) if raw else cfg.learning_rate


def resolve_max_length(cfg: TrainConfig) -> int:
    """Max sequence length for this deployment, from MAX_LENGTH env (or the
    config default)."""
    raw = os.environ.get("MAX_LENGTH")
    return int(raw) if raw else cfg.max_length


def resolve_grad_accum(cfg: TrainConfig) -> int:
    """Gradient-accumulation size for this deployment, from GRAD_ACCUM env
    (or the config default). The number of optimizer steps a call runs is
    derived from the number of uploaded examples:  ceil(num_examples /
    grad_accum). 10 examples with GRAD_ACCUM=4 -> 3 steps (4+4+2); 1 example
    -> 1 step."""
    raw = os.environ.get("GRAD_ACCUM")
    val = int(raw) if raw else cfg.grad_accum
    return max(1, val)


def _device_name() -> str:
    return "cuda" if torch.cuda.is_available() else "cpu"


def _device_map():
    """Where to load the model: auto-shard across every available GPU, else a
    single GPU, else CPU.

    ``device_map="auto"`` lets accelerate spread a large model across all
    visible GPUs (e.g. a 27B base model across 4 GPUs) so the *same code path*
    trains a 27B model on 4 GPUs as a 1B model on 1 GPU β€” no manual setup.
    With 0/1 GPUs accelerate returns a single device (no sharding, so the
    Trainer's single-device path is used). The model only spans multiple
    devices when >1 GPU is present.
    """
    if torch.cuda.is_available():
        return "auto"  # accelerate distributes across all visible GPUs
    return "cpu"


def _dtype_for_shard(model_id: str, is_sharded: bool):
    """Load a multi-GPU-sharded model in float16 so the per-GPU footprint
    roughly halves (a 27B model = ~54GB in fp32 vs ~27GB in fp16, splitting
    across 4 GPUs becomes trivially affordable). Single-device / CPU models
    stay float32 for accuracy. LoRA training is robust to a fp16 base."""
    if is_sharded and torch.cuda.is_available():
        return torch.float16
    return torch.float32


# A module-level cache of the loaded base model. Populated once on the first
# call and reused on every subsequent call, so the expensive download + load
# happens a single time per deployment. The base (frozen) weights never
# change between calls; only the LoRA adapter + optimizer state do, and those
# are reset from the resumed checkpoint on each call β€” so reuse is safe.
_MODEL_CACHE: dict = {}


def _model_cache_key(model_id: str) -> str:
    return model_id


def _load_model_fresh(model_id: str, cfg: TrainConfig):
    """Download + load the base model and wrap it in a fresh LoRA adapter.

    The device is chosen automatically: ``device_map="auto"`` shards the model
    across every visible GPU (so a 27B model on 4 GPUs loads the same way a 1B
    model loads on 1 GPU), otherwise it uses a single GPU or CPU. A
    multi-GPU-sharded model is loaded in fp16 to halve the per-GPU footprint;
    single-device / CPU models stay fp32 for accuracy.
    """
    num_gpus = torch.cuda.device_count() if torch.cuda.is_available() else 0
    is_sharded = num_gpus > 1
    device_map = _device_map()
    torch_dtype = _dtype_for_shard(model_id, is_sharded)
    tok = AutoTokenizer.from_pretrained(model_id, token=_resolve_token())
    model = AutoModelForCausalLM.from_pretrained(
        model_id,
        token=_resolve_token(),
        torch_dtype=torch_dtype,
        device_map=device_map,
    )
    if tok.pad_token is None:
        tok.pad_token = tok.eos_token
    # A base (non-instruct) model may have no chat template; SFT with chat
    # traces needs one. Inject a simple default so any base model works.
    if getattr(tok, "chat_template", None) is None:
        tok.chat_template = DEFAULT_CHAT_TEMPLATE

    lora = LoraConfig(
        r=cfg.lora_r,
        lora_alpha=cfg.lora_alpha,
        lora_dropout=cfg.lora_dropout,
        target_modules=list(cfg.target_modules),
        bias="none",
        task_type="CAUSAL_LM",
    )
    model = get_peft_model(model, lora)
    model.print_trainable_parameters()
    if is_sharded:
        # Log the sharding so the user can see how the model was spread.
        devices = sorted({str(d) for d in set(model.hf_device_map.values())})
        print(f"[train] model {model_id} sharded across {len(devices)} device(s): "
              f"{', '.join(devices)} ({num_gpus} GPU, {torch_dtype})")
    return model, tok


def _model_is_cached(model_id: str) -> bool:
    return model_id in _MODEL_CACHE and _MODEL_CACHE[model_id] is not None


def get_model_and_tokenizer(model_id: str, cfg: TrainConfig):
    """Return a model (PEFT-wrapped) + tokenizer for the deployment.

    When the device is *persistent* (not ZeroGPU) the loaded base model is
    cached in a module-level dict and reused across calls β€” the whole point:
    the base model stays resident in RAM/VRAM instead of being re-downloaded /
    re-loaded on every request. The frozen base weights never change between
    calls; only the LoRA adapter + optimizer state do, and those are reset
    from the resumed checkpoint on each call, so reuse is safe.

    On ZeroGPU the GPU is *transient* (detached between calls), so we skip the
    cache and load per-call from the local HF cache (which still avoids a
    network re-download β€” only the load is repeated).

    Note: LoRA hyper-params (rank / alpha) are fixed at first-load time. To
    change them mid-deployment, restart the Space (which re-runs the cache).
    """
    # ZeroGPU's GPU is transient: never keep a cached device ref across calls.
    if _ZERO_GPU:
        return _load_model_fresh(model_id, cfg)

    cached = _MODEL_CACHE.get(model_id)
    if cached is not None:
        return cached

    model, tok = _load_model_fresh(model_id, cfg)
    _MODEL_CACHE[model_id] = (model, tok)
    return model, tok


def _build_config(cfg: TrainConfig, output_dir: Path, num_examples: int,
                  current_step: int):
    """Build a TRL training config for a single call.

    **Dynamic gradient accumulation.** The number of optimizer steps this call
    runs is derived from how many examples the uploaded trace contains and the
    deployment's gradient-accumulation size ``G`` (``GRAD_ACCUM`` env), *not*
    from a fixed "steps per call":

        steps_this_call = ceil(num_examples / (per_device_batch * accum))

    with ``accum = ceil(G / per_device_batch)``. With ``G=4`` this is exactly
    the requested behaviour: a single trace -> 1 step; 10 traces -> 3 steps
    (4 + 4 + 2). SFT/DPO use a per-device batch of 1 (so one optimizer step ==
    ``G`` examples, matching the example precisely); KTO uses a per-device
    batch of 2 (its KL term is undefined for a single example), so its per-step
    example count is ``2 * ceil(G/2)``.

    ``max_steps`` is the *absolute* target (``current_step + steps_this_call``);
    with ``resume_from_checkpoint`` the Trainer runs the remaining steps, so the
    call continues exactly where the last call left off.
    """
    import math

    use_gpu = torch.cuda.is_available()
    use_cpu = not use_gpu
    m = cfg.method.lower()
    # KTO needs an actual batch > 1 (its KL term is undefined for a single
    # example); SFT/DPO use a per-device batch of 1.
    per_device_batch = 2 if m == "kto" else 1
    accum = max(1, math.ceil(cfg.grad_accum / per_device_batch))
    examples_per_step = per_device_batch * accum
    steps_this_call = max(1, math.ceil(num_examples / examples_per_step)) if num_examples else 1
    target = current_step + steps_this_call

    common = dict(
        output_dir=str(output_dir),
        per_device_train_batch_size=per_device_batch,
        gradient_accumulation_steps=accum,
        max_steps=target,  # absolute target; resume continues from here
        learning_rate=cfg.learning_rate,
        lr_scheduler_type=cfg.lr_scheduler_type,
        optim=cfg.optim,
        weight_decay=cfg.weight_decay,
        max_grad_norm=1.0,
        logging_strategy="steps",
        logging_steps=1,
        save_strategy="steps",
        save_steps=1,
        save_total_limit=cfg.save_total_limit,
        save_only_model=False,  # MUST keep optimizer.pt / scheduler.pt / rng
        use_cpu=use_cpu,        # GPU when available, else CPU
        report_to="none",
        dataloader_num_workers=0,
        remove_unused_columns=False,
        ignore_data_skip=True,  # robust resume for tiny/arbitrary datasets
    )
    if m == "sft":
        from trl import SFTConfig

        args = SFTConfig(**common, max_length=cfg.max_length)
    elif m == "dpo":
        from trl import DPOConfig

        args = DPOConfig(**common, max_length=cfg.max_length)
    elif m == "kto":
        from trl import KTOConfig

        args = KTOConfig(**common, max_length=cfg.max_length)
    else:
        raise ValueError(f"Unknown method {cfg.method!r}")
    return args, steps_this_call


def _build_trainer(method: str, model, tok, dataset, args):
    if method == "sft":
        from trl import SFTTrainer

        return SFTTrainer(model=model, args=args, train_dataset=dataset, processing_class=tok)
    if method == "dpo":
        from trl import DPOTrainer

        # ref_model=None + a PEFT model => trl uses the adapter-disabled model
        # as the reference (standard LoRA-DPO pattern).
        return DPOTrainer(model=model, args=args, train_dataset=dataset, processing_class=tok)
    if method == "kto":
        from trl import KTOTrainer

        return KTOTrainer(model=model, args=args, train_dataset=dataset, processing_class=tok)
    raise ValueError(f"Unknown method {method!r}")


# --------------------------------------------------------------------------- #
#  The main entry point
# --------------------------------------------------------------------------- #
def _steps_for_call(cfg: TrainConfig, num_examples: int) -> int:
    """Dynamic optimizer-step count for a single call.

    ``steps = ceil(num_examples / (per_device_batch * accum))`` with
    ``accum = ceil(cfg.grad_accum / per_device_batch)``. SFT/DPO use a
    per-device batch of 1, so ``steps = ceil(num_examples / grad_accum)`` β€”
    exactly the requested behaviour (10 examples, grad_accum=4 -> 3 steps;
    1 example -> 1 step). KTO uses a per-device batch of 2. A call with no
    examples still runs 1 step (keeps the optimizer warm / makes the call
    idempotent)."""
    import math

    m = cfg.method.lower()
    per_device_batch = 2 if m == "kto" else 1
    accum = max(1, math.ceil(cfg.grad_accum / per_device_batch))
    examples_per_step = per_device_batch * accum
    if not num_examples:
        return 1
    return max(1, math.ceil(num_examples / examples_per_step))


def run_one_call(
    rows: list[dict],
    cfg: TrainConfig,
    *,
    log=print,
) -> TrainResult:
    """Parse the uploaded trace, run ``steps_per_call`` optimizer steps for the
    chosen method (resuming from the latest checkpoint if one exists), then
    persist the resulting weights + optimizer state to the bucket.

    The uploaded ``rows`` (the training data) are never written anywhere β€”
    only the trained weights and optimizer/scheduler state are persisted.
    """
    import time

    start = time.time()
    # One bucket + one model per deployment: resolve from the env when the
    # caller did not pin them (the UI no longer asks for them per request).
    cfg.model_id = resolve_model_id(cfg)
    cfg.bucket_id = resolve_bucket_id(cfg)
    # LoRA hyper-params are also fixed per deployment via env (one model + one
    # bucket + one LoRA config per deployment). They are resolved here so the
    # resident model cache (keyed by model_id) wraps the *deployment's* LoRA
    # config at first-load, and every later call reuses the same cached model.
    cfg.lora_r = resolve_lora_r(cfg)
    cfg.lora_alpha = resolve_lora_alpha(cfg)
    cfg.lora_dropout = resolve_lora_dropout(cfg)
    cfg.target_modules = resolve_target_modules(cfg)
    cfg.learning_rate = resolve_learning_rate(cfg)
    cfg.max_length = resolve_max_length(cfg)
    cfg.grad_accum = resolve_grad_accum(cfg)
    m = cfg.method.lower()
    dataset = parse_trace(rows, m)
    num_examples = len(dataset)
    log(f"[train] model={cfg.model_id}  bucket={cfg.bucket_id or '(none)'}  "
        f"parsed {num_examples} {m} examples from trace  "
        f"(grad_accum={cfg.grad_accum})")

    notes: list[str] = []

    # --- resolve resume checkpoint -------------------------------------- #
    local_cache = LOCAL_CACHE
    local_cache.mkdir(parents=True, exist_ok=True)
    resume_dir = None
    resumed_from = None
    if cfg.resume:
        resume_dir, resumed_from = _resolve_resume(
            cfg, local_cache, notes
        )
        if resume_dir:
            log(f"[train] resuming from {resumed_from}")
    current_step = _read_global_step(resume_dir) if resume_dir else 0

    # --- dynamic gradient accumulation: how many steps this call runs -- #
    steps_this_call = _steps_for_call(cfg, num_examples)
    target = current_step + steps_this_call
    output_dir = local_cache / f"run-{target}"
    log(
        f"[train] {num_examples} example(s) / grad_accum={cfg.grad_accum} "
        f"-> {steps_this_call} step(s): {current_step} -> {target}"
    )

    args, steps_this_call = _build_config(cfg, output_dir, num_examples, current_step)

    # --- build model + trainer (resident cache keeps the base model in
    #     RAM/VRAM across calls; the adapter+optimizer state is reset from the
    #     resumed checkpoint below) ------------------------- #
    model, tok = get_model_and_tokenizer(cfg.model_id, cfg)
    trainer = _build_trainer(m, model, tok, dataset, args)

    # --- train (resume continues from current_step to target) ---------- #
    train_args = {}
    if resume_dir:
        train_args["resume_from_checkpoint"] = resume_dir
    log(f"[train] training steps {current_step} -> {target}")
    train_res = trainer.train(**train_args)

    # --- read back the new global step + loss -------------------------- #
    new_step = current_step
    loss = None
    try:
        st = trainer.state
        new_step = int(getattr(st, "global_step", current_step) or current_step)
    except Exception:
        pass
    logs = getattr(train_res, "log", None) or []
    if logs:
        for entry in logs:
            if "loss" in entry:
                loss = float(entry["loss"])
    if loss is None:
        try:
            metrics = getattr(train_res, "metrics", None) or {}
            train_metrics = metrics.get("train", {})
            if "train_loss" in train_metrics:
                loss = float(train_metrics["train_loss"])
        except Exception:
            pass

    # --- persist the latest checkpoint to the bucket ------------------- #
    checkpoint_id = f"checkpoint-{new_step}"
    saved_dir = _find_checkpoint_dir(output_dir, new_step) or output_dir
    if cfg.bucket_id:
        try:
            push_checkpoint(cfg.bucket_id, saved_dir, checkpoint_id)
            save_state_index(
                cfg.bucket_id,
                {
                    "global_step": new_step,
                    "method": m,
                    "model_id": cfg.model_id,
                    "loss": loss,
                },
            )
            log(f"[train] pushed {checkpoint_id} to bucket {cfg.bucket_id}")
        except Exception as exc:
            notes.append(f"push failed: {exc}")
            log(f"[train] PUSH FAILED: {exc}")
    else:
        notes.append("no bucket_id set β€” weights NOT persisted")

    result = TrainResult(
        method=m,
        model_id=cfg.model_id,
        global_step=new_step,
        loss=loss,
        checkpoint_id=checkpoint_id,
        resumed_from=resumed_from,
        num_examples=num_examples,
        grad_accum=cfg.grad_accum,
        steps_run=new_step - current_step,
        notes=notes,
        elapsed=time.time() - start,
    )
    log(f"[train] done: {result.as_dict()}")
    return result


# --------------------------------------------------------------------------- #
#  helpers
# --------------------------------------------------------------------------- #
def _resolve_resume(cfg: TrainConfig, local_cache: Path, notes: list) -> tuple[str | None, str | None]:
    """Decide which checkpoint to resume from.

    1. Prefer a fresh local checkpoint from a previous call (same session) β€”
       so back-to-back calls resume even without a bucket.
    2. Otherwise pull the latest checkpoint from the bucket.
    """
    # 1) fresh local checkpoint from a prior call
    local = _find_any_checkpoint(local_cache)
    if local is not None:
        return str(local), local.name
    # 2) latest from the bucket
    if cfg.bucket_id:
        try:
            latest = _latest_checkpoint_prefix(cfg.bucket_id)
        except Exception as exc:  # pragma: no cover
            latest = None
            notes.append(f"could not list bucket: {exc}")
        if latest:
            local_dir = local_cache / f"pull-{latest}"
            if pull_checkpoint(cfg.bucket_id, latest, local_dir):
                return str(local_dir), latest
            notes.append(f"could not download {latest}")
    return None, None


def _find_any_checkpoint(root: Path):
    """Return the local ``.../checkpoint-N`` dir with the highest N under
    ``root`` (searches run-*/checkpoint-* and top-level checkpoint-*)."""
    best = None
    for cand in list(root.glob("checkpoint-*")) + list(root.glob("run-*/checkpoint-*")):
        s = _dir_step(cand)
        if best is None or s > _dir_step(best):
            best = cand
    return best


def _latest_checkpoint_prefix(bucket_id: str) -> str | None:
    try:
        from store import list_latest_checkpoint

        return list_latest_checkpoint(bucket_id)
    except Exception:
        return None


def _read_global_step(checkpoint_dir: str | None) -> int:
    if not checkpoint_dir:
        return 0
    p = Path(checkpoint_dir) / "trainer_state.json"
    try:
        data = json.loads(p.read_text())
        return int(data.get("global_step", 0))
    except Exception:
        return 0


def _find_checkpoint_dir(output_dir: Path, step: int) -> Path | None:
    cand = output_dir / f"checkpoint-{step}"
    if cand.exists():
        return cand
    # the trainer may have saved under a different name; take the max one
    best = None
    for d in output_dir.glob("checkpoint-*"):
        try:
            s = int(d.name.split("-")[-1])
        except Exception:
            continue
        if best is None or s > int(_dir_step(best)):
            best = d
    return best


def _dir_step(d: Path) -> int:
    try:
        return int(d.name.split("-")[-1])
    except Exception:
        return -1


def current_progress(bucket_id: str) -> dict:
    """Lightweight status: latest checkpoint + global step from the index."""
    try:
        from store import load_state_index

        idx = load_state_index(bucket_id)
    except Exception:
        idx = {}
    latest = None
    try:
        latest = _latest_checkpoint_prefix(bucket_id)
    except Exception:
        pass
    return {
        "global_step": idx.get("global_step", 0),
        "latest_checkpoint": latest,
        "checkpoints": checkpoint_paths(bucket_id),
    }