Project-Chronos 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. chronos/__init__.py +16 -0
  2. chronos/_backend_legacy.py +64 -0
  3. chronos/app.py +122 -0
  4. chronos/backend/__init__.py +40 -0
  5. chronos/backend/dispatcher.py +347 -0
  6. chronos/backend/ext/__init__.py +2 -0
  7. chronos/backend/ext/opencl.py +22 -0
  8. chronos/cli.py +62 -0
  9. chronos/data/__init__.py +0 -0
  10. chronos/data/flexible_dataset.py +309 -0
  11. chronos/deps.py +72 -0
  12. chronos/eval/__init__.py +2 -0
  13. chronos/eval/benchmark.py +159 -0
  14. chronos/eval/io_profiler.py +232 -0
  15. chronos/io/__init__.py +13 -0
  16. chronos/io/async_prefetcher.py +220 -0
  17. chronos/io/cluster_layout.py +317 -0
  18. chronos/io/expert_store.py +559 -0
  19. chronos/io/io_simulator.py +40 -0
  20. chronos/io/storage.py +237 -0
  21. chronos/mlx/__init__.py +32 -0
  22. chronos/mlx/attention.py +230 -0
  23. chronos/mlx/expert_store.py +155 -0
  24. chronos/mlx/inference.py +159 -0
  25. chronos/mlx/model.py +181 -0
  26. chronos/mlx/moe.py +102 -0
  27. chronos/model/__init__.py +6 -0
  28. chronos/model/config.py +54 -0
  29. chronos/model/hf_io.py +149 -0
  30. chronos/model/hybrid_attention.py +237 -0
  31. chronos/model/lookahead_router.py +55 -0
  32. chronos/model/model_chronos.py +271 -0
  33. chronos/model/moe_chronos.py +189 -0
  34. chronos/model/temporal_loss.py +124 -0
  35. chronos/router/__init__.py +12 -0
  36. chronos/router/expert_predictor.py +163 -0
  37. chronos/router/intent_classifier.py +193 -0
  38. chronos/router/prefill_scheduler.py +248 -0
  39. chronos/runtime/__init__.py +2 -0
  40. chronos/runtime/cache_manager.py +117 -0
  41. chronos/runtime/inference_engine.py +295 -0
  42. chronos/runtime/metrics.py +75 -0
  43. chronos/serving/__init__.py +13 -0
  44. chronos/serving/vllm_adapter.py +85 -0
  45. chronos/trainer/__init__.py +6 -0
  46. chronos/trainer/chronos_trainer.py +151 -0
  47. chronos/trainer/distill_trainer.py +193 -0
  48. chronos/trainer/dpo_trainer.py +190 -0
  49. chronos/trainer/grpo_trainer.py +288 -0
  50. chronos/trainer/loss_mixin.py +165 -0
  51. chronos/trainer/optim_utils.py +101 -0
  52. chronos/trainer/orpo_trainer.py +189 -0
  53. chronos/trainer/reward.py +130 -0
  54. chronos/trainer/sft_trainer.py +189 -0
  55. chronos/tuning/__init__.py +1 -0
  56. chronos/tuning/_base_tuner.py +284 -0
  57. chronos/tuning/chronos_auto_tuner.py +203 -0
  58. project_chronos-0.1.1.dist-info/METADATA +670 -0
  59. project_chronos-0.1.1.dist-info/RECORD +83 -0
  60. project_chronos-0.1.1.dist-info/WHEEL +5 -0
  61. project_chronos-0.1.1.dist-info/entry_points.txt +7 -0
  62. project_chronos-0.1.1.dist-info/licenses/LICENSE +177 -0
  63. project_chronos-0.1.1.dist-info/top_level.txt +8 -0
  64. train_chronos.py +133 -0
  65. train_chronos_distill.py +95 -0
  66. train_chronos_dpo.py +82 -0
  67. train_chronos_grpo.py +88 -0
  68. train_chronos_orpo.py +81 -0
  69. train_chronos_sft.py +85 -0
  70. ui/__init__.py +3 -0
  71. ui/estimator.py +204 -0
  72. ui/gradio_compat.py +72 -0
  73. ui/i18n.py +540 -0
  74. ui/presets.py +194 -0
  75. ui/tabs/__init__.py +3 -0
  76. ui/tabs/autotune_tab.py +210 -0
  77. ui/tabs/benchmark_tab.py +196 -0
  78. ui/tabs/config_tab.py +318 -0
  79. ui/tabs/data_tab.py +148 -0
  80. ui/tabs/inference_tab.py +496 -0
  81. ui/tabs/iomon_tab.py +37 -0
  82. ui/tabs/pipeline_tab.py +134 -0
  83. ui/tabs/train_tab.py +1688 -0
ui/tabs/train_tab.py ADDED
@@ -0,0 +1,1688 @@
1
+ """
2
+ ui/tabs/train_tab.py — Full training loop: Pretrain / SFT / DPO / ORPO / GRPO / Distill
3
+ """
4
+ import os
5
+ import glob
6
+ import time
7
+ import threading
8
+ import queue
9
+ from contextlib import contextmanager
10
+ from types import SimpleNamespace
11
+
12
+ from ui.gradio_compat import gr
13
+
14
+ import chronos.deps # auto-bootstrap minimind on sys.path
15
+ from ui.i18n import t, register_translatable, get_current_lang
16
+ from chronos.backend import training_available, resolve_training_device
17
+
18
+
19
+ STAGE_UI_ORDER = ["pretrain", "sft", "dpo", "orpo", "grpo", "distill"]
20
+ STAGE_LABELS = {
21
+ "pretrain": "Stage 1 · Pretrain",
22
+ "sft": "Stage 2 · SFT",
23
+ "dpo": "Stage 3 · DPO",
24
+ "orpo": "Stage 4 · ORPO",
25
+ "grpo": "Stage 5 · GRPO",
26
+ "distill": "Stage 6 · Distill",
27
+ }
28
+ STAGE_CHECKPOINT_PREFIX = {
29
+ "pretrain": "chronos",
30
+ "sft": "sft",
31
+ "dpo": "dpo",
32
+ "orpo": "orpo",
33
+ "grpo": "grpo",
34
+ "distill": "distill",
35
+ }
36
+ STAGE_DEFAULT_INIT = {
37
+ "sft": "chronos",
38
+ "dpo": "sft",
39
+ "orpo": "sft",
40
+ "grpo": "orpo",
41
+ "distill": "grpo",
42
+ }
43
+ STAGE_DEFAULT_DATA = {
44
+ "pretrain": "./tests/fixtures/tiny_pretrain.jsonl",
45
+ "sft": "./tests/fixtures/tiny_sft.jsonl",
46
+ "dpo": "./tests/fixtures/tiny_dpo.jsonl",
47
+ "orpo": "./tests/fixtures/tiny_dpo.jsonl",
48
+ "grpo": "./tests/fixtures/tiny_grpo.jsonl",
49
+ "distill": "./tests/fixtures/tiny_sft.jsonl",
50
+ }
51
+ CHECKPOINT_TOPOLOGY_KEYS = (
52
+ "hidden_size",
53
+ "num_hidden_layers",
54
+ "num_experts",
55
+ "moe_intermediate_size",
56
+ "vocab_size",
57
+ "lookahead_steps",
58
+ "kv_latent_dim",
59
+ "rope_dim",
60
+ "num_attention_heads",
61
+ "num_key_value_heads",
62
+ )
63
+ STAGE_HELP_TEXT = {
64
+ "pretrain": {
65
+ "zh-Hans": "从通用语料继续预训练。`init_weight` 可留空;若存在同名 checkpoint,将按当前拓扑尝试恢复。",
66
+ "zh-Hant": "從通用語料繼續預訓練。`init_weight` 可留空;若存在同名 checkpoint,將按目前拓撲嘗試恢復。",
67
+ "en": "Continue base pretraining on LM-style data. `init_weight` may be left blank; if a matching checkpoint exists, Train will resume from it.",
68
+ "ja": "汎用コーパスで事前学習を続けます。`init_weight` は空で構いません。同名 checkpoint があり拓撲が一致すれば再開します。",
69
+ },
70
+ "sft": {
71
+ "zh-Hans": "使用 `conversations` 数据做监督微调。`init_weight` 留空时,会从当前 `save_dir` 自动接续 `chronos_<H>_moe.pth`;只有手动填路径时才覆盖。",
72
+ "zh-Hant": "使用 `conversations` 資料做監督微調。`init_weight` 留空時,會從目前 `save_dir` 自動接續 `chronos_<H>_moe.pth`;只有手動填路徑時才覆蓋。",
73
+ "en": "Run supervised fine-tuning on `conversations` data. If `init_weight` is blank, Train auto-resolves `chronos_<H>_moe.pth` from the current `save_dir`; only a pasted path overrides it.",
74
+ "ja": "`conversations` データで SFT を行います。`init_weight` を空欄にすると、現在の `save_dir` から `chronos_<H>_moe.pth` を自動解決します。パスを入力した場合のみ上書きします。",
75
+ },
76
+ "dpo": {
77
+ "zh-Hans": "使用 `chosen / rejected` 偏好对做 DPO。`init_weight` 留空时,会自动加载当前 `save_dir` 下的 `sft_<H>_moe.pth`;手动填写路径可覆盖。",
78
+ "zh-Hant": "使用 `chosen / rejected` 偏好對做 DPO。`init_weight` 留空時,會自動載入目前 `save_dir` 下的 `sft_<H>_moe.pth`;手動填寫路徑可覆蓋。",
79
+ "en": "Run DPO on `chosen / rejected` preference pairs. Leaving `init_weight` blank auto-loads `sft_<H>_moe.pth` from the current `save_dir`; paste a path only when you want to override it.",
80
+ "ja": "`chosen / rejected` の選好ペアで DPO を実行します。`init_weight` を空欄にすると、現在の `save_dir` から `sft_<H>_moe.pth` を自動読み込みします。上書きしたい場合だけパスを入力してください。",
81
+ },
82
+ "orpo": {
83
+ "zh-Hans": "使用同样的偏好对做 ORPO。`init_weight` 留空时,会自动加载当前 `save_dir` 下的 `sft_<H>_moe.pth`;手动填写路径可覆盖。",
84
+ "zh-Hant": "使用相同的偏好對做 ORPO。`init_weight` 留空時,會自動載入目前 `save_dir` 下的 `sft_<H>_moe.pth`;手動填寫路徑可覆蓋。",
85
+ "en": "Run ORPO on the same preference-pair format. Leaving `init_weight` blank auto-loads `sft_<H>_moe.pth` from the current `save_dir`; paste a path only when you want to override it.",
86
+ "ja": "同じ選好ペア形式で ORPO を実行します。`init_weight` を空欄にすると、現在の `save_dir` から `sft_<H>_moe.pth` を自動読み込みします。上書きしたい場合だけパスを入力してください。",
87
+ },
88
+ "grpo": {
89
+ "zh-Hans": "使用 prompt 数据做 GRPO rollout。`init_weight` 留空时,会自动加载当前 `save_dir` 下的 `orpo_<H>_moe.pth`;`reward` 可配置。",
90
+ "zh-Hant": "使用 prompt 資料做 GRPO rollout。`init_weight` 留空時,會自動載入目前 `save_dir` 下的 `orpo_<H>_moe.pth`;`reward` 可設定。",
91
+ "en": "Run GRPO rollouts on prompt-style data. Leaving `init_weight` blank auto-loads `orpo_<H>_moe.pth` from the current `save_dir`; `reward` remains configurable.",
92
+ "ja": "prompt 形式のデータで GRPO rollout を行います。`init_weight` を空欄にすると、現在の `save_dir` から `orpo_<H>_moe.pth` を自動読み込みします。`reward` は設定できます。",
93
+ },
94
+ "distill": {
95
+ "zh-Hans": "蒸馏阶段中,学生 `init_weight` 留空时会自动接续当前 `save_dir` 下的 `grpo_<H>_moe.pth`;`teacher_path` 留空时会自动解析 `sft_<H>_moe.pth`。只有手动填路径时才覆盖。",
96
+ "zh-Hant": "蒸餾階段中,學生 `init_weight` 留空時會自動接續目前 `save_dir` 下的 `grpo_<H>_moe.pth`;`teacher_path` 留空時會自動解析 `sft_<H>_moe.pth`。只有手動填路徑時才覆蓋。",
97
+ "en": "For distillation, a blank student `init_weight` auto-resolves `grpo_<H>_moe.pth` from the current `save_dir`, and a blank `teacher_path` auto-resolves `sft_<H>_moe.pth`. Paste paths only when you want to override either one.",
98
+ "ja": "蒸留では、学生側の `init_weight` を空欄にすると現在の `save_dir` から `grpo_<H>_moe.pth` を自動解決し、`teacher_path` を空欄にすると `sft_<H>_moe.pth` を自動解決します。上書きしたい場合だけパスを入力してください。",
99
+ },
100
+ }
101
+ STAGE_METRIC_LABELS = {
102
+ "pretrain": ("Total Loss", "CE Loss", "Aux Loss"),
103
+ "sft": ("SFT Loss", "CE Loss", "Lookahead"),
104
+ "dpo": ("DPO Loss", "Preference", "Anchor"),
105
+ "orpo": ("ORPO Loss", "Chosen NLL", "OR Term"),
106
+ "grpo": ("GRPO Loss", "Policy", "KL"),
107
+ "distill": ("Distill Loss", "KD Loss", "CE Loss"),
108
+ }
109
+ STAGE_CHART_META = {
110
+ "pretrain": {
111
+ "title": "Pretrain Metrics",
112
+ "primary": "CE",
113
+ "secondary": "Aux",
114
+ "tertiary": "Temporal",
115
+ "val": "Validation",
116
+ "speed_title": "Training Speed (steps/s)",
117
+ "speed_label": "steps/s",
118
+ },
119
+ "sft": {
120
+ "title": "SFT Metrics",
121
+ "primary": "CE",
122
+ "secondary": "Lookahead",
123
+ "tertiary": "Anchor",
124
+ "val": "Validation",
125
+ "speed_title": "SFT Speed (steps/s)",
126
+ "speed_label": "steps/s",
127
+ },
128
+ "dpo": {
129
+ "title": "DPO Metrics",
130
+ "primary": "Preference",
131
+ "secondary": "Anchor",
132
+ "tertiary": "Lookahead",
133
+ "val": "Validation",
134
+ "speed_title": "Preference Speed (steps/s)",
135
+ "speed_label": "steps/s",
136
+ },
137
+ "orpo": {
138
+ "title": "ORPO Metrics",
139
+ "primary": "Chosen NLL",
140
+ "secondary": "OR Term",
141
+ "tertiary": "Anchor",
142
+ "val": "Validation",
143
+ "speed_title": "ORPO Speed (steps/s)",
144
+ "speed_label": "steps/s",
145
+ },
146
+ "grpo": {
147
+ "title": "GRPO Metrics",
148
+ "primary": "Policy",
149
+ "secondary": "KL",
150
+ "tertiary": "Reward",
151
+ "val": "Validation",
152
+ "speed_title": "Rollout Speed + Reward",
153
+ "speed_label": "rollouts/s",
154
+ "speed_overlay": "Mean Reward",
155
+ },
156
+ "distill": {
157
+ "title": "Distill Metrics",
158
+ "primary": "KD",
159
+ "secondary": "CE",
160
+ "tertiary": "Anchor",
161
+ "val": "Validation",
162
+ "speed_title": "Distill Speed (steps/s)",
163
+ "speed_label": "steps/s",
164
+ },
165
+ }
166
+
167
+
168
+ DEFAULT_STAGE_SAMPLE_PROMPTS = {
169
+ "pretrain": {
170
+ "zh-Hans": "请用简洁中文介绍 Project Chronos 如何把 MoE 的专家加载提前到 Prefill 阶段。",
171
+ "zh-Hant": "請用精簡中文介紹 Project Chronos 如何把 MoE 的專家載入提前到 Prefill 階段。",
172
+ "en": "Explain how Project Chronos moves MoE expert loading into the prefill stage.",
173
+ "ja": "Project Chronos が MoE の専門家ロードを Prefill 段階へ前倒しする仕組みを説明してください。",
174
+ },
175
+ "sft": {
176
+ "zh-Hans": "用户:为什么 Chronos 的双层路由比传统被动 offload 更适合消费级硬件?\n助手:",
177
+ "zh-Hant": "使用者:為什麼 Chronos 的雙層路由比傳統被動 offload 更適合消費級硬體?\n助手:",
178
+ "en": "User: Why is Chronos's dual-layer routing better suited to consumer hardware than reactive expert offload?\nAssistant:",
179
+ "ja": "ユーザー: なぜ Chronos の二層ルーティングは、従来の受動的な expert offload よりも民生向けハードウェアに向いているのですか?\nアシスタント:",
180
+ },
181
+ "dpo": {
182
+ "zh-Hans": "用户:请比较 Chronos 和传统 reactive offload 在推理时的关键差异。\n助手:",
183
+ "zh-Hant": "使用者:請比較 Chronos 與傳統 reactive offload 在推理時的關鍵差異。\n助手:",
184
+ "en": "User: Compare Chronos with conventional reactive offload during inference.\nAssistant:",
185
+ "ja": "ユーザー: 推論時における Chronos と従来の reactive offload の主要な違いを比較してください。\nアシスタント:",
186
+ },
187
+ "orpo": {
188
+ "zh-Hans": "用户:请写一段面向开发者的 Project Chronos 介绍。\n助手:",
189
+ "zh-Hant": "使用者:請寫一段面向開發者的 Project Chronos 介紹。\n助手:",
190
+ "en": "User: Write a developer-facing introduction to Project Chronos.\nAssistant:",
191
+ "ja": "ユーザー: 開発者向けの Project Chronos 紹介文を書いてください。\nアシスタント:",
192
+ },
193
+ "grpo": {
194
+ "zh-Hans": "请比较 Chronos 与传统 MoE offload 在吞吐、首 token 延迟和缓存未命中行为上的权衡。",
195
+ "zh-Hant": "請比較 Chronos 與傳統 MoE offload 在吞吐、首 token 延遲和快取未命中行為上的權衡。",
196
+ "en": "Compare Chronos with conventional MoE offload in terms of throughput, first-token latency, and cache-miss behavior.",
197
+ "ja": "Chronos と従来の MoE offload を、スループット、初回トークン遅延、キャッシュミス時の挙動で比較してください。",
198
+ },
199
+ "distill": {
200
+ "zh-Hans": "请用更现代、更有产品感的英文改写 Project Chronos 的首页介绍,保留核心技术点。",
201
+ "zh-Hant": "請用更現代、更有產品感的英文改寫 Project Chronos 的首頁介紹,保留核心技術點。",
202
+ "en": "Rewrite the Project Chronos landing-page intro in a more modern product voice while preserving the core technical claims.",
203
+ "ja": "Project Chronos のトップ紹介文を、技術的な要点を保ったまま、よりモダンでプロダクト寄りの英語に書き換えてください。",
204
+ },
205
+ }
206
+
207
+
208
+ def _stage_sample_prompt(mode: str, lang: str | None = None) -> str:
209
+ lang = lang or get_current_lang()
210
+ prompts = DEFAULT_STAGE_SAMPLE_PROMPTS.get(mode, DEFAULT_STAGE_SAMPLE_PROMPTS["pretrain"])
211
+ return prompts.get(lang, prompts["en"])
212
+
213
+
214
+ def _default_sample_prompts() -> set[str]:
215
+ vals: set[str] = set()
216
+ for prompts in DEFAULT_STAGE_SAMPLE_PROMPTS.values():
217
+ vals.update(v.strip() for v in prompts.values() if v and v.strip())
218
+ return vals
219
+
220
+
221
+ DEFAULT_SAMPLE_PROMPT_VALUES = _default_sample_prompts()
222
+ DEFAULT_STAGE_DATA_VALUES = {v.strip() for v in STAGE_DEFAULT_DATA.values() if v.strip()}
223
+ DEFAULT_STAGE_INIT_VALUES = {
224
+ f"./out/{prefix}_768_moe.pth"
225
+ for prefix in STAGE_DEFAULT_INIT.values()
226
+ }
227
+
228
+ PRETRAIN_INIT_PLACEHOLDER = {
229
+ "zh-Hans": "留空则按当前 save_dir 自动续训同阶段 checkpoint",
230
+ "zh-Hant": "留空則按目前 save_dir 自動續訓同階段 checkpoint",
231
+ "en": "Leave blank to auto-resume the current-stage checkpoint from save_dir",
232
+ "ja": "空欄なら save_dir から同一ステージ checkpoint を自動再開",
233
+ }
234
+ UPSTREAM_INIT_PLACEHOLDER = {
235
+ "zh-Hans": "留空则按当前 save_dir 自动载入 {prefix}_<H>_moe.pth,手动填写才覆盖",
236
+ "zh-Hant": "留空則按目前 save_dir 自動載入 {prefix}_<H>_moe.pth,手動填寫才覆蓋",
237
+ "en": "Leave blank to auto-load {prefix}_<H>_moe.pth from save_dir; paste a path to override",
238
+ "ja": "空欄なら save_dir から {prefix}_<H>_moe.pth を自動読み込み。上書き時だけパスを指定",
239
+ }
240
+ DISTILL_TEACHER_PLACEHOLDER = {
241
+ "zh-Hans": "留空则按当前 save_dir 自动载入 sft_<H>_moe.pth,手动填写才覆盖",
242
+ "zh-Hant": "留空則按目前 save_dir 自動載入 sft_<H>_moe.pth,手動填寫才覆蓋",
243
+ "en": "Leave blank to auto-load sft_<H>_moe.pth from save_dir; paste a path to override",
244
+ "ja": "空欄なら save_dir から sft_<H>_moe.pth を自動読み込み。上書き時だけパスを指定",
245
+ }
246
+
247
+ TRAIN_BACKEND_CHOICES = ["auto", "cuda", "xpu", "mps", "cpu"]
248
+
249
+ TRAIN_BACKEND_LABELS = {
250
+ "auto": {
251
+ "zh-Hans": "自动 (默认最快)",
252
+ "zh-Hant": "自動 (預設最快)",
253
+ "en": "Auto (fastest default)",
254
+ "ja": "自動 (最速を既定)",
255
+ },
256
+ "cuda": {
257
+ "zh-Hans": "CUDA",
258
+ "zh-Hant": "CUDA",
259
+ "en": "CUDA",
260
+ "ja": "CUDA",
261
+ },
262
+ "mps": {
263
+ "zh-Hans": "MPS",
264
+ "zh-Hant": "MPS",
265
+ "en": "MPS",
266
+ "ja": "MPS",
267
+ },
268
+ "xpu": {
269
+ "zh-Hans": "XPU",
270
+ "zh-Hant": "XPU",
271
+ "en": "XPU",
272
+ "ja": "XPU",
273
+ },
274
+ "cpu": {
275
+ "zh-Hans": "CPU",
276
+ "zh-Hant": "CPU",
277
+ "en": "CPU",
278
+ "ja": "CPU",
279
+ },
280
+ }
281
+
282
+
283
+ def _stage_text(stage_map: dict[str, str], mode: str, lang: str | None = None) -> str:
284
+ lang = lang or get_current_lang()
285
+ per_stage = stage_map.get(mode, stage_map["pretrain"])
286
+ return per_stage.get(lang, per_stage["en"])
287
+
288
+
289
+ def _stage_init_placeholder(mode: str, lang: str | None = None) -> str:
290
+ lang = lang or get_current_lang()
291
+ if mode == "pretrain":
292
+ return PRETRAIN_INIT_PLACEHOLDER.get(lang, PRETRAIN_INIT_PLACEHOLDER["en"])
293
+ upstream = STAGE_DEFAULT_INIT.get(mode, "chronos")
294
+ template = UPSTREAM_INIT_PLACEHOLDER.get(lang, UPSTREAM_INIT_PLACEHOLDER["en"])
295
+ return template.format(prefix=upstream)
296
+
297
+
298
+ def _normalize_stage_init_value(current_init_weight: str | None) -> str:
299
+ current_init = (current_init_weight or "").strip()
300
+ if current_init in DEFAULT_STAGE_INIT_VALUES:
301
+ return ""
302
+ return current_init
303
+
304
+
305
+ def _distill_teacher_placeholder(lang: str | None = None) -> str:
306
+ lang = lang or get_current_lang()
307
+ return DISTILL_TEACHER_PLACEHOLDER.get(lang, DISTILL_TEACHER_PLACEHOLDER["en"])
308
+
309
+
310
+ def _available_train_backend_choices() -> list[str]:
311
+ available = set(training_available())
312
+ return ["auto"] + [name for name in TRAIN_BACKEND_CHOICES[1:] if name in available]
313
+
314
+
315
+ def _train_backend_label(name: str, lang: str | None = None) -> str:
316
+ lang = lang or get_current_lang()
317
+ labels = TRAIN_BACKEND_LABELS.get(name, TRAIN_BACKEND_LABELS["auto"])
318
+ return labels.get(lang, labels["en"])
319
+
320
+
321
+ def _train_backend_dropdown_choices(lang: str | None = None) -> list[tuple[str, str]]:
322
+ return [
323
+ (_train_backend_label(name, lang), name)
324
+ for name in _available_train_backend_choices()
325
+ ]
326
+
327
+
328
+ def _default_train_backend_value() -> str:
329
+ return "auto"
330
+
331
+
332
+ def _normalize_dtype_for_trainer(dtype: str | None) -> str:
333
+ dt = (dtype or "fp16").strip().lower()
334
+ if dt in {"bf16", "bfloat16"}:
335
+ return "bfloat16"
336
+ return "float16"
337
+
338
+
339
+ @contextmanager
340
+ def _trainer_logger_sink(put_line):
341
+ from trainer import trainer_utils as _tu # type: ignore
342
+
343
+ orig_logger = _tu.Logger
344
+
345
+ def _hook(content):
346
+ line = str(content)
347
+ put_line(line)
348
+ try:
349
+ orig_logger(content)
350
+ except Exception:
351
+ pass
352
+
353
+ _tu.Logger = _hook
354
+ try:
355
+ yield
356
+ finally:
357
+ _tu.Logger = orig_logger
358
+
359
+
360
+ def _sniff_checkpoint(path: str) -> dict:
361
+ import os as _os
362
+ if not path or not _os.path.exists(path):
363
+ return {}
364
+ try:
365
+ import torch as _t
366
+ sd = _t.load(path, map_location="cpu")
367
+ if not isinstance(sd, dict):
368
+ return {}
369
+ out = {}
370
+
371
+ embed = sd.get("model.embed_tokens.weight")
372
+ if embed is not None:
373
+ out["vocab_size"], out["hidden_size"] = int(embed.shape[0]), int(embed.shape[1])
374
+
375
+ layer_idxs = set()
376
+ for k in sd.keys():
377
+ if k.startswith("model.layers."):
378
+ try:
379
+ layer_idxs.add(int(k.split(".")[2]))
380
+ except (ValueError, IndexError):
381
+ pass
382
+ if layer_idxs:
383
+ out["num_hidden_layers"] = max(layer_idxs) + 1
384
+
385
+ expert_idxs = set()
386
+ for k in sd.keys():
387
+ if k.startswith("model.layers.0.mlp.experts."):
388
+ try:
389
+ expert_idxs.add(int(k.split(".")[5]))
390
+ except (ValueError, IndexError):
391
+ pass
392
+ if expert_idxs:
393
+ out["num_experts"] = max(expert_idxs) + 1
394
+
395
+ gate = sd.get("model.layers.0.mlp.experts.0.gate_proj.weight")
396
+ if gate is not None:
397
+ out["moe_intermediate_size"] = int(gate.shape[0])
398
+
399
+ qw = sd.get("model.layers.0.self_attn.q_proj.weight")
400
+ kw = sd.get("model.layers.0.self_attn.k_proj.weight")
401
+ if qw is not None and kw is not None:
402
+ out["num_attention_heads_total_dim"] = int(qw.shape[0])
403
+ out["num_kv_heads_total_dim"] = int(kw.shape[0])
404
+
405
+ lookahead_proj = sd.get("model.lookahead_router.proj.2.weight")
406
+ if lookahead_proj is not None and out.get("num_experts"):
407
+ total = int(lookahead_proj.shape[0])
408
+ n_exp = int(out["num_experts"])
409
+ if n_exp > 0 and total % n_exp == 0:
410
+ # The router predicts current-token routing plus K future
411
+ # steps, so the saved output rows are (lookahead_steps + 1)
412
+ # * num_experts. Do not report the +1 as user topology.
413
+ out["lookahead_steps"] = max(0, total // n_exp - 1)
414
+
415
+ qnope = sd.get("model.layers.0.self_attn.q_nope_proj.weight")
416
+ qrope = sd.get("model.layers.0.self_attn.q_rope_proj.weight")
417
+ kvdown = sd.get("model.layers.0.self_attn.kv_down_proj.weight")
418
+ vproj = sd.get("model.layers.0.self_attn.v_proj.weight")
419
+ if qnope is not None and qrope is not None and out.get("hidden_size"):
420
+ candidates = [
421
+ n for n in range(1, out["hidden_size"] + 1)
422
+ if out["hidden_size"] % n == 0
423
+ and int(qrope.shape[0]) % n == 0
424
+ and int(qnope.shape[0]) % n == 0
425
+ ]
426
+ if candidates:
427
+ preferred = 8 if 8 in candidates else max(candidates)
428
+ head_dim = out["hidden_size"] // preferred
429
+ rope_dim = int(qrope.shape[0]) // preferred
430
+ nope_dim = int(qnope.shape[0]) // preferred
431
+ if rope_dim + nope_dim == head_dim:
432
+ out["num_attention_heads"] = preferred
433
+ out["rope_dim"] = rope_dim
434
+ if kvdown is not None:
435
+ out["kv_latent_dim"] = int(kvdown.shape[0])
436
+ if vproj is not None and out.get("kv_latent_dim"):
437
+ head_dim = out.get("hidden_size", 0) // max(out.get("num_attention_heads", 8), 1)
438
+ if head_dim > 0:
439
+ out["num_key_value_heads"] = max(1, int(vproj.shape[0]) // head_dim)
440
+
441
+ return out
442
+ except Exception:
443
+ return {}
444
+
445
+
446
+ # ── Background trainer thread ─────────────────────────────────────
447
+
448
+ class TrainSession:
449
+ def __init__(self):
450
+ self._stop = threading.Event()
451
+ self._thread = None
452
+ self._log_q: queue.Queue = queue.Queue(maxsize=5000)
453
+ self.status = "idle"
454
+ self.step = 0
455
+ self.loss = None
456
+ # Separate metric history for charts
457
+ self._metrics: list[dict] = [] # [{step, total, ce, aux, temporal, tps}]
458
+ self._metric_lock = threading.Lock()
459
+ # Progress + ETA — set when training kicks off
460
+ self.total_steps: int = 0
461
+ self.t_start: float = 0.0
462
+ # M8a: live generation sample (updated each save_interval).
463
+ self.last_sample: str = ""
464
+ self.last_sample_step: int = 0
465
+
466
+ def is_running(self):
467
+ return self._thread is not None and self._thread.is_alive()
468
+
469
+ def start(self, cfg: dict, mode: str):
470
+ if self.is_running():
471
+ return
472
+ self._stop.clear()
473
+ self.status = "running"
474
+ self.step = 0
475
+ self.loss = None
476
+ self._metrics.clear()
477
+ self.total_steps = 0
478
+ self.t_start = time.monotonic()
479
+ self._thread = threading.Thread(
480
+ target=self._run, args=(cfg, mode), daemon=True
481
+ )
482
+ self._thread.start()
483
+
484
+ def stop(self):
485
+ self._stop.set()
486
+ self.status = "stopped"
487
+
488
+ def _put(self, msg: str):
489
+ try:
490
+ self._log_q.put_nowait(msg)
491
+ except queue.Full:
492
+ pass
493
+
494
+ def _put_metric(self, m: dict):
495
+ with self._metric_lock:
496
+ self._metrics.append(m)
497
+
498
+ def drain_log(self):
499
+ """Return all pending log lines as a single string (no newline prefix)."""
500
+ lines = []
501
+ while True:
502
+ try:
503
+ lines.append(self._log_q.get_nowait())
504
+ except queue.Empty:
505
+ break
506
+ return "\n".join(lines)
507
+
508
+ def get_metrics(self):
509
+ with self._metric_lock:
510
+ return list(self._metrics)
511
+
512
+ def _stage_checkpoint_path(self, save_dir: str, mode: str, hidden_size: int) -> str:
513
+ prefix = STAGE_CHECKPOINT_PREFIX.get(mode, "chronos")
514
+ return os.path.join(save_dir, f"{prefix}_{hidden_size}_moe.pth")
515
+
516
+ def _default_init_path(self, save_dir: str, mode: str, hidden_size: int) -> str:
517
+ upstream = STAGE_DEFAULT_INIT.get(mode)
518
+ if not upstream:
519
+ return ""
520
+ return os.path.join(save_dir, f"{upstream}_{hidden_size}_moe.pth")
521
+
522
+ def _resolve_default_init_path(self, save_dir: str, mode: str, hidden_size: int) -> str:
523
+ exact = self._default_init_path(save_dir, mode, hidden_size)
524
+ if os.path.exists(exact):
525
+ return exact
526
+ upstream = STAGE_DEFAULT_INIT.get(mode)
527
+ if not upstream:
528
+ return exact
529
+ candidates = [
530
+ p for p in glob.glob(os.path.join(save_dir, f"{upstream}_*_moe.pth"))
531
+ if os.path.isfile(p)
532
+ ]
533
+ if not candidates:
534
+ return exact
535
+ return max(candidates, key=os.path.getmtime)
536
+
537
+ def _topology_mismatches(self, sniffed: dict, model_cfg_kwargs: dict) -> list[str]:
538
+ mismatches = []
539
+ for k in CHECKPOINT_TOPOLOGY_KEYS:
540
+ if k in sniffed and k in model_cfg_kwargs and int(sniffed[k]) != int(model_cfg_kwargs[k]):
541
+ mismatches.append(f"{k}: ckpt={sniffed[k]} != ui={model_cfg_kwargs[k]}")
542
+ return mismatches
543
+
544
+ def _adopt_checkpoint_topology(self, model_cfg_kwargs: dict, sniffed: dict) -> dict:
545
+ adopted = {}
546
+ for key in CHECKPOINT_TOPOLOGY_KEYS:
547
+ if key not in sniffed:
548
+ continue
549
+ current = model_cfg_kwargs.get(key)
550
+ value = sniffed[key]
551
+ if current is None or int(current) != int(value):
552
+ adopted[key] = value
553
+ model_cfg_kwargs[key] = value
554
+ if "moe_intermediate_size" in sniffed:
555
+ model_cfg_kwargs["intermediate_size"] = sniffed["moe_intermediate_size"]
556
+ return adopted
557
+
558
+ def _build_stage_args(self, cfg: dict, mode: str, save_dir: str, hidden_size: int):
559
+ reward_spec = (cfg.get("reward_spec") or "toy").strip() or "toy"
560
+ teacher_path = (cfg.get("teacher_path") or "").strip()
561
+ return SimpleNamespace(
562
+ device=cfg.get("device", "cpu"),
563
+ dtype=_normalize_dtype_for_trainer(cfg.get("dtype")),
564
+ learning_rate=float(cfg.get("learning_rate", 5e-4)),
565
+ accumulation_steps=max(1, int(cfg.get("accumulation_steps", 1))),
566
+ grad_clip=float(cfg.get("grad_clip", 1.0)),
567
+ log_interval=max(1, int(cfg.get("log_interval", 10))),
568
+ save_interval=max(1, int(cfg.get("save_interval", 500))),
569
+ save_dir=save_dir,
570
+ epochs=max(1, int(cfg.get("epochs", 1))),
571
+ batch_size=max(1, int(cfg.get("batch_size", 1))),
572
+ max_seq_len=max(8, int(cfg.get("max_seq_len", 512))),
573
+ max_gen_len=max(1, int(cfg.get("max_gen_len", 24))),
574
+ num_generations=max(1, int(cfg.get("num_generations", 4))),
575
+ temperature=float(cfg.get("temperature", 1.0)),
576
+ beta=float(cfg.get("beta", 0.1)),
577
+ alpha=float(cfg.get("alpha", 0.7)),
578
+ lambda_or=float(cfg.get("lambda_or", 0.1)),
579
+ lambda_router_anchor=float(cfg.get("lambda_router_anchor", 0.0)),
580
+ reward=reward_spec,
581
+ teacher_path=teacher_path or os.path.join(save_dir, f"sft_{hidden_size}_moe.pth"),
582
+ hidden_size=hidden_size,
583
+ num_hidden_layers=int(cfg.get("num_hidden_layers", 8)),
584
+ num_experts=int(cfg.get("num_experts", 4)),
585
+ steps=int(cfg.get("max_steps", 0)) or None,
586
+ weight_decay=float(cfg.get("weight_decay", 0.01)),
587
+ )
588
+
589
+ def _record_stage_metric(self, mode: str, step: int, result, tps: float = 0.0):
590
+ meta = STAGE_CHART_META.get(mode, STAGE_CHART_META["pretrain"])
591
+ if isinstance(result, tuple):
592
+ if mode == "sft":
593
+ total, ce_loss, aux_loss, lookahead, anchor = result
594
+ metric = {
595
+ "total": total,
596
+ "primary": ce_loss,
597
+ "secondary": lookahead,
598
+ "tertiary": anchor,
599
+ "ce": ce_loss,
600
+ "aux": lookahead,
601
+ "temporal": anchor,
602
+ }
603
+ elif mode == "dpo":
604
+ total, pref_loss, _aux_loss, lookahead, anchor = result
605
+ metric = {
606
+ "total": total,
607
+ "primary": pref_loss,
608
+ "secondary": anchor,
609
+ "tertiary": lookahead,
610
+ "ce": pref_loss,
611
+ "aux": anchor,
612
+ "temporal": lookahead,
613
+ }
614
+ elif mode == "orpo":
615
+ total, chosen_nll, or_term, _lookahead, anchor = result
616
+ metric = {
617
+ "total": total,
618
+ "primary": chosen_nll,
619
+ "secondary": or_term,
620
+ "tertiary": anchor,
621
+ "ce": chosen_nll,
622
+ "aux": or_term,
623
+ "temporal": anchor,
624
+ }
625
+ elif mode == "distill":
626
+ total, kd_loss, ce_loss, _lookahead, anchor = result
627
+ metric = {
628
+ "total": total,
629
+ "primary": kd_loss,
630
+ "secondary": ce_loss,
631
+ "tertiary": anchor,
632
+ "ce": kd_loss,
633
+ "aux": ce_loss,
634
+ "temporal": anchor,
635
+ }
636
+ else:
637
+ return
638
+ elif isinstance(result, dict) and mode == "grpo":
639
+ metric = {
640
+ "total": result.get("loss", 0.0),
641
+ "primary": result.get("pg_loss", 0.0),
642
+ "secondary": result.get("kl", 0.0),
643
+ "tertiary": result.get("mean_reward", 0.0),
644
+ "ce": result.get("pg_loss", 0.0),
645
+ "aux": result.get("kl", 0.0),
646
+ "temporal": result.get("mean_reward", 0.0),
647
+ }
648
+ else:
649
+ return
650
+
651
+ if mode == "pretrain":
652
+ metric.setdefault("primary", metric.get("ce", 0.0))
653
+ metric.setdefault("secondary", metric.get("aux", 0.0))
654
+ metric.setdefault("tertiary", metric.get("temporal", 0.0))
655
+
656
+ metric.update({"step": int(step), "tps": float(tps)})
657
+ self.step = int(step)
658
+ self.loss = float(metric["total"])
659
+ self._put_metric(metric)
660
+ self._put(
661
+ f"[{mode.upper()} summary] step={step} total={metric['total']:.4f} "
662
+ f"{meta['primary']}={metric.get('primary', 0.0):.4f} "
663
+ f"{meta['secondary']}={metric.get('secondary', 0.0):.4f} "
664
+ f"{meta['tertiary']}={metric.get('tertiary', 0.0):.4f} "
665
+ f"tps={tps:.2f}"
666
+ )
667
+
668
+ def _generate_live_sample(self, model, device: str, cfg: dict, mode: str):
669
+ import torch
670
+
671
+ sp = (cfg.get("sample_prompt") or "").strip()
672
+ if not sp:
673
+ sp = _stage_sample_prompt(mode)
674
+ tokenizer = self._load_tokenizer()
675
+ ids = tokenizer.encode(sp, return_tensors="pt").to(device)
676
+ was_training = model.training
677
+ model.eval()
678
+ with torch.no_grad():
679
+ gen = ids.clone()
680
+ for _ in range(80):
681
+ o, _ = model(gen, use_cache=False)
682
+ nxt = o.logits[:, -1, :].argmax(dim=-1, keepdim=True)
683
+ gen = torch.cat([gen, nxt], dim=-1)
684
+ if int(nxt.item()) == (tokenizer.eos_token_id or -1):
685
+ break
686
+ txt = tokenizer.decode(gen[0].tolist(), skip_special_tokens=True)
687
+ if was_training:
688
+ model.train()
689
+ self.last_sample = txt
690
+ self.last_sample_step = self.step or self.last_sample_step
691
+ self._put(f"[sample @ step {self.last_sample_step or 0}] {txt[:200]}")
692
+
693
+ def _run(self, cfg: dict, mode: str):
694
+ t_start = time.monotonic()
695
+ try:
696
+ import torch
697
+ from torch.utils.data import DataLoader
698
+
699
+ from chronos.model.config import ChronosConfig
700
+ from chronos.model.model_chronos import ChronosForCausalLM
701
+ from chronos.model.moe_chronos import ChronosMOEFeedForward
702
+
703
+ self._put(f"[{mode.upper()}] Building model...")
704
+ # Pass the FULL config dict so user-set arch overrides (e.g.
705
+ # moe_intermediate_size, num_attention_heads, vocab_size) are
706
+ # actually honored. Previously this method only forwarded a
707
+ # hardcoded subset, so the trainer silently used minimind's
708
+ # auto-derived intermediate_size while the UI estimator showed
709
+ # the user's slider — causing 10x param-count mismatches.
710
+ model_cfg_kwargs = {
711
+ "hidden_size": cfg.get("hidden_size", 512),
712
+ "num_hidden_layers": cfg.get("num_hidden_layers", 8),
713
+ "num_experts": cfg.get("num_experts", 4),
714
+ "num_experts_per_tok": cfg.get("num_experts_per_tok", 1),
715
+ "num_shared_experts": cfg.get("num_shared_experts", 1),
716
+ "lookahead_steps": cfg.get("lookahead_steps", 2),
717
+ "kv_latent_dim": cfg.get("kv_latent_dim", 64),
718
+ "sliding_window_size": cfg.get("sliding_window_size", 2048),
719
+ "lambda_balance": cfg.get("lambda_balance", 5e-4),
720
+ "lambda_temporal": cfg.get("lambda_temporal", 1e-3),
721
+ "vram_budget_gb": cfg.get("vram_budget_gb", 4.0),
722
+ "use_hybrid_attention": cfg.get("use_hybrid_attention", True),
723
+ "use_moe": True,
724
+ }
725
+ # Optional overrides: only forward when the user actually set them.
726
+ # For the "auto" sentinel fields (0 = let MiniMindConfig derive
727
+ # it), we skip forwarding when value is 0 so ceil(H·π/64)·64
728
+ # takes effect instead of Linear(H, 0) crashing MPS init.
729
+ AUTO_SENTINEL_KEYS = {"intermediate_size", "moe_intermediate_size"}
730
+ for opt_key, cfg_key in [
731
+ ("num_attention_heads", "num_attention_heads"),
732
+ ("num_key_value_heads", "num_key_value_heads"),
733
+ ("rope_dim", "rope_dim"),
734
+ ("vocab_size", "vocab_size"),
735
+ ("max_position_embeddings", "max_seq_len"),
736
+ ("intermediate_size", "moe_intermediate_size"),
737
+ ("moe_intermediate_size", "moe_intermediate_size"),
738
+ ("lambda_lookahead", "lambda_lookahead"),
739
+ ("lambda_router_anchor", "lambda_router_anchor"),
740
+ ("pinned_memory_max_fraction", "pinned_memory_max_fraction"),
741
+ ("storage_format", "storage_format"),
742
+ ("tie_word_embeddings", "tie_word_embeddings"),
743
+ ]:
744
+ if cfg_key not in cfg:
745
+ continue
746
+ val = cfg[cfg_key]
747
+ if val in (None, ""):
748
+ continue
749
+ if opt_key in AUTO_SENTINEL_KEYS and val == 0:
750
+ continue
751
+ model_cfg_kwargs[opt_key] = val
752
+ save_dir = cfg.get("save_dir", "./out")
753
+ init_weight = (cfg.get("init_weight") or "").strip()
754
+ load_path = ""
755
+ init_sniffed = {}
756
+ if mode != "pretrain":
757
+ load_path = init_weight or self._resolve_default_init_path(
758
+ save_dir, mode, int(model_cfg_kwargs["hidden_size"])
759
+ )
760
+ if os.path.exists(load_path):
761
+ init_sniffed = _sniff_checkpoint(load_path)
762
+ adopted = self._adopt_checkpoint_topology(model_cfg_kwargs, init_sniffed)
763
+ if adopted:
764
+ summary = ", ".join(f"{k}={v}" for k, v in adopted.items())
765
+ self._put(
766
+ f"[{mode.upper()}] Adopted topology from init checkpoint "
767
+ f"{load_path}: {summary}"
768
+ )
769
+
770
+ model_cfg = ChronosConfig(**model_cfg_kwargs)
771
+ model = ChronosForCausalLM(model_cfg)
772
+ params_m = sum(p.numel() for p in model.parameters()) / 1e6
773
+ self._put(f"Model: {params_m:.1f}M params "
774
+ f"(H={model_cfg.hidden_size}, L={model_cfg.num_hidden_layers}, "
775
+ f"E={model_cfg.num_experts}, ffn={model_cfg.intermediate_size}, "
776
+ f"vocab={model_cfg.vocab_size})")
777
+
778
+ ckp_path = self._stage_checkpoint_path(save_dir, mode, model_cfg.hidden_size)
779
+
780
+ # Init-weight resolution:
781
+ # - pretrain: optionally resume from its own checkpoint if present
782
+ # - other stages: require an upstream weight (explicit init_weight
783
+ # or the stage's default predecessor checkpoint).
784
+ if mode == "pretrain":
785
+ resume_path = init_weight or ckp_path
786
+ if os.path.exists(resume_path):
787
+ sniffed = _sniff_checkpoint(resume_path)
788
+ mismatches = self._topology_mismatches(sniffed, model_cfg_kwargs)
789
+ if mismatches:
790
+ self._put(
791
+ "Resume checkpoint exists but does not match current UI config; "
792
+ "starting fresh instead.\n " + "\n ".join(mismatches)
793
+ )
794
+ else:
795
+ weights = torch.load(resume_path, map_location="cpu")
796
+ model.load_state_dict(weights, strict=False)
797
+ self._put(f"Resumed from {resume_path}")
798
+ else:
799
+ self._put("Pretraining from random init")
800
+ else:
801
+ if not os.path.exists(load_path):
802
+ raise FileNotFoundError(
803
+ f"[{mode.upper()}] requires an upstream checkpoint to initialize from. "
804
+ f"Tried: {load_path}\n"
805
+ f"Set 'init_weight' in the Train tab to point at a valid .pth, "
806
+ f"or run the prior stage first."
807
+ )
808
+
809
+ sniffed = init_sniffed or _sniff_checkpoint(load_path)
810
+ mismatch_hints = self._topology_mismatches(sniffed, model_cfg_kwargs)
811
+ if mismatch_hints:
812
+ raise RuntimeError(
813
+ f"[{mode.upper()}] init checkpoint topology does not match current UI config:\n "
814
+ + "\n ".join(mismatch_hints)
815
+ + "\nLoad the matching preset/config first, or switch init_weight to the correct checkpoint."
816
+ )
817
+
818
+ weights = torch.load(load_path, map_location="cpu")
819
+ model.load_state_dict(weights, strict=False)
820
+ self._put(f"[{mode.upper()}] Initialized from {load_path}")
821
+
822
+ requested_backend = (cfg.get("train_backend") or "auto").strip().lower() or "auto"
823
+ resolved_backend, device = resolve_training_device(requested_backend)
824
+ cfg["train_backend"] = requested_backend
825
+ cfg["device"] = device
826
+ self._put(f"Training backend: {resolved_backend} (requested: {requested_backend})")
827
+ self._put(f"Device: {device}")
828
+ model = model.to(device)
829
+ from chronos.trainer.optim_utils import build_optimizer, get_lr, apply_lr
830
+
831
+ data_path = cfg.get("data_path", "")
832
+ max_seq_len = cfg.get("max_seq_len", 512)
833
+ batch_size = cfg.get("batch_size", 4)
834
+ accum = max(1, int(cfg.get("accumulation_steps", 8)))
835
+ epochs = cfg.get("epochs", 1)
836
+ save_interval = cfg.get("save_interval", 500)
837
+ log_interval = max(1, cfg.get("log_interval", 10))
838
+ val_ratio = float(cfg.get("val_ratio", 0.05))
839
+ max_steps = int(cfg.get("max_steps", 0)) or None # 0 => no cap
840
+ base_lr = float(cfg.get("learning_rate", 5e-4))
841
+ weight_decay = float(cfg.get("weight_decay", 0.01))
842
+ optimizer = build_optimizer(model, lr=base_lr, weight_decay=weight_decay)
843
+
844
+ if not data_path or not os.path.exists(data_path):
845
+ self._put("No dataset found — running synthetic smoke test (50 steps)")
846
+ loader = self._synthetic_loader(model_cfg.vocab_size, max_seq_len, batch_size, n=50)
847
+ val_loader = None
848
+ else:
849
+ tokenizer = self._load_tokenizer()
850
+ from chronos.data.flexible_dataset import (
851
+ FlexibleDataset, StreamingSFTDataset, StreamingDPODataset,
852
+ )
853
+ if mode == "pretrain":
854
+ full_ds = FlexibleDataset(data_path, tokenizer, max_length=max_seq_len)
855
+ elif mode in {"sft", "distill"}:
856
+ full_ds = StreamingSFTDataset(data_path, tokenizer, max_length=max_seq_len)
857
+ elif mode in {"dpo", "orpo"}:
858
+ full_ds = StreamingDPODataset(data_path, tokenizer, max_length=max_seq_len)
859
+ else:
860
+ full_ds = None
861
+ # Deterministic val split: every 1/val_ratio-th record goes to val
862
+ # (idx-modulo split → reproducible without rescanning the file).
863
+ if full_ds is not None and val_ratio > 0 and len(full_ds) >= 20:
864
+ stride = max(2, int(round(1.0 / val_ratio)))
865
+ val_idx = [i for i in range(len(full_ds)) if i % stride == 0]
866
+ train_idx = [i for i in range(len(full_ds)) if i % stride != 0]
867
+ from torch.utils.data import Subset
868
+ train_ds = Subset(full_ds, train_idx)
869
+ val_ds = Subset(full_ds, val_idx)
870
+ self._put(f"Split: train={len(train_ds)} val={len(val_ds)}")
871
+ elif full_ds is not None:
872
+ train_ds, val_ds = full_ds, None
873
+ else:
874
+ train_ds, val_ds = None, None
875
+ loader = (
876
+ DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0, pin_memory=False)
877
+ if train_ds is not None else None
878
+ )
879
+ val_loader = (
880
+ DataLoader(val_ds, batch_size=batch_size, shuffle=False,
881
+ num_workers=0, pin_memory=False)
882
+ if val_ds is not None else None
883
+ )
884
+
885
+ if mode in {"grpo"}:
886
+ tokenizer = self._load_tokenizer()
887
+ from chronos.trainer.grpo_trainer import load_grpo_prompts
888
+ prompts = load_grpo_prompts(data_path, max_prompts=max_steps)
889
+ if not prompts:
890
+ raise ValueError(f"[{mode.upper()}] no prompts found in {data_path}")
891
+ planned = max(1, len(prompts) * epochs)
892
+ self.total_steps = min(planned, max_steps) if (max_steps and planned) else planned
893
+ else:
894
+ prompts = None
895
+
896
+ model.train()
897
+ global_step = 0
898
+ step_t = time.monotonic()
899
+ best_val = float("inf")
900
+ best_path = ckp_path.replace(".pth", ".best.pth")
901
+ # Steps remaining for ETA / progress bar — respect max_steps cap.
902
+ try:
903
+ planned = max(1, len(loader) * epochs) if loader is not None else 0
904
+ except Exception:
905
+ planned = 0
906
+ if mode != "grpo":
907
+ self.total_steps = min(planned, max_steps) if (max_steps and planned) else (planned or (max_steps or 0))
908
+
909
+ stage_args = self._build_stage_args(cfg, mode, save_dir, model_cfg.hidden_size)
910
+ stage_args.device = device
911
+
912
+ if mode in {"sft", "dpo", "orpo", "grpo", "distill"}:
913
+ tokenizer = self._load_tokenizer()
914
+
915
+ def _eval_val() -> float:
916
+ if val_loader is None:
917
+ return float("nan")
918
+ model.eval()
919
+ tot, n = 0.0, 0
920
+ with torch.no_grad():
921
+ for k, (vx, vy) in enumerate(val_loader):
922
+ if k >= 50: # cap eval cost
923
+ break
924
+ vx = vx.to(device); vy = vy.to(device)
925
+ vout, _ = model(vx, labels=vy)
926
+ tot += float(vout.loss.item()); n += 1
927
+ model.train()
928
+ return tot / max(n, 1)
929
+
930
+ if mode in {"sft", "dpo", "orpo", "grpo", "distill"}:
931
+ with _trainer_logger_sink(self._put):
932
+ if mode == "sft":
933
+ from chronos.trainer.sft_trainer import ChronosSFTTrainer
934
+
935
+ trainer = ChronosSFTTrainer(model, model_cfg, stage_args, tokenizer)
936
+ if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and loader is not None:
937
+ first_batch = next(iter(loader))
938
+ trainer.set_calibration_batch(first_batch[0])
939
+ self._put("[SFT] Router anchor reference captured.")
940
+ iters = len(loader) if stage_args.steps is None else min(stage_args.steps, len(loader))
941
+ self.total_steps = max(1, stage_args.epochs * iters)
942
+ for epoch in range(stage_args.epochs):
943
+ epoch_start = time.monotonic()
944
+ for step_idx, (ids, labels) in enumerate(loader, start=1):
945
+ if self._stop.is_set():
946
+ break
947
+ if stage_args.steps is not None and step_idx > stage_args.steps:
948
+ break
949
+ result = trainer.train_step(ids, labels, epoch * iters + step_idx, self.total_steps)
950
+ if step_idx % stage_args.log_interval == 0 or step_idx == iters:
951
+ elapsed = max(time.monotonic() - epoch_start, 1e-6)
952
+ self._record_stage_metric(mode, epoch * iters + step_idx, result, tps=step_idx / elapsed)
953
+ if step_idx % stage_args.save_interval == 0:
954
+ trainer._save(epoch, step_idx)
955
+ self._generate_live_sample(model, device, cfg, mode)
956
+ if self._stop.is_set() or stage_args.steps is not None:
957
+ break
958
+ trainer._save(epoch=stage_args.epochs - 1, step=iters)
959
+ elif mode == "dpo":
960
+ from chronos.trainer.dpo_trainer import ChronosDPOTrainer
961
+
962
+ trainer = ChronosDPOTrainer(model, model_cfg, stage_args, tokenizer)
963
+ if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and loader is not None:
964
+ first = next(iter(loader))
965
+ calib = torch.cat([first["x_chosen"], first["x_rejected"]], dim=0)
966
+ trainer.set_calibration_batch(calib)
967
+ self._put("[DPO] Router anchor reference captured.")
968
+ iters = len(loader) if stage_args.steps is None else min(stage_args.steps, len(loader))
969
+ self.total_steps = max(1, stage_args.epochs * iters)
970
+ for epoch in range(stage_args.epochs):
971
+ epoch_start = time.monotonic()
972
+ for step_idx, batch in enumerate(loader, start=1):
973
+ if self._stop.is_set():
974
+ break
975
+ if stage_args.steps is not None and step_idx > stage_args.steps:
976
+ break
977
+ result = trainer.train_step(batch, epoch * iters + step_idx, self.total_steps)
978
+ if step_idx % stage_args.log_interval == 0 or step_idx == iters:
979
+ elapsed = max(time.monotonic() - epoch_start, 1e-6)
980
+ self._record_stage_metric(mode, epoch * iters + step_idx, result, tps=step_idx / elapsed)
981
+ if step_idx % stage_args.save_interval == 0:
982
+ trainer._save(epoch, step_idx)
983
+ self._generate_live_sample(model, device, cfg, mode)
984
+ if self._stop.is_set() or stage_args.steps is not None:
985
+ break
986
+ trainer._save(epoch=stage_args.epochs - 1, step=iters)
987
+ elif mode == "orpo":
988
+ from chronos.trainer.orpo_trainer import ChronosORPOTrainer
989
+
990
+ trainer = ChronosORPOTrainer(model, model_cfg, stage_args, tokenizer)
991
+ if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and loader is not None:
992
+ first = next(iter(loader))
993
+ calib = torch.cat([first["x_chosen"], first["x_rejected"]], dim=0)
994
+ trainer.set_calibration_batch(calib)
995
+ self._put("[ORPO] Router anchor reference captured.")
996
+ iters = len(loader) if stage_args.steps is None else min(stage_args.steps, len(loader))
997
+ self.total_steps = max(1, stage_args.epochs * iters)
998
+ for epoch in range(stage_args.epochs):
999
+ epoch_start = time.monotonic()
1000
+ for step_idx, batch in enumerate(loader, start=1):
1001
+ if self._stop.is_set():
1002
+ break
1003
+ if stage_args.steps is not None and step_idx > stage_args.steps:
1004
+ break
1005
+ result = trainer.train_step(batch, epoch * iters + step_idx, self.total_steps)
1006
+ if step_idx % stage_args.log_interval == 0 or step_idx == iters:
1007
+ elapsed = max(time.monotonic() - epoch_start, 1e-6)
1008
+ self._record_stage_metric(mode, epoch * iters + step_idx, result, tps=step_idx / elapsed)
1009
+ if step_idx % stage_args.save_interval == 0:
1010
+ trainer._save(epoch, step_idx)
1011
+ self._generate_live_sample(model, device, cfg, mode)
1012
+ if self._stop.is_set() or stage_args.steps is not None:
1013
+ break
1014
+ trainer._save(epoch=stage_args.epochs - 1, step=iters)
1015
+ elif mode == "grpo":
1016
+ from chronos.trainer.grpo_trainer import ChronosGRPOTrainer
1017
+ from chronos.trainer.reward import build_reward_fn
1018
+
1019
+ reward_fn = build_reward_fn(stage_args.reward)
1020
+ trainer = ChronosGRPOTrainer(model, model_cfg, stage_args, tokenizer, reward_fn=reward_fn)
1021
+ if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and prompts:
1022
+ calib = tokenizer(
1023
+ prompts[0], return_tensors="pt", truncation=True, max_length=stage_args.max_seq_len
1024
+ ).input_ids
1025
+ trainer.set_calibration_batch(calib)
1026
+ self._put("[GRPO] Router anchor reference captured.")
1027
+ iters = len(prompts) if stage_args.steps is None else min(stage_args.steps, len(prompts))
1028
+ self.total_steps = max(1, stage_args.epochs * iters)
1029
+ for epoch in range(stage_args.epochs):
1030
+ epoch_start = time.monotonic()
1031
+ for step_idx, prompt in enumerate(prompts, start=1):
1032
+ if self._stop.is_set():
1033
+ break
1034
+ if step_idx > iters:
1035
+ break
1036
+ result = trainer.train_step(prompt, epoch * iters + step_idx, self.total_steps)
1037
+ if step_idx % stage_args.log_interval == 0 or step_idx == iters:
1038
+ elapsed = max(time.monotonic() - epoch_start, 1e-6)
1039
+ self._record_stage_metric(mode, epoch * iters + step_idx, result, tps=step_idx / elapsed)
1040
+ if step_idx % stage_args.save_interval == 0:
1041
+ trainer._save(epoch, step_idx)
1042
+ self._generate_live_sample(model, device, cfg, mode)
1043
+ if self._stop.is_set() or stage_args.steps is not None:
1044
+ break
1045
+ trainer._save(epoch=stage_args.epochs - 1, step=iters)
1046
+ elif mode == "distill":
1047
+ from chronos.trainer.distill_trainer import ChronosDistillTrainer
1048
+
1049
+ teacher_path = stage_args.teacher_path
1050
+ if not os.path.exists(teacher_path):
1051
+ raise FileNotFoundError(f"[DISTILL] teacher not found: {teacher_path}")
1052
+ teacher = ChronosForCausalLM(model_cfg).to(device)
1053
+ t_state = torch.load(teacher_path, map_location=device)
1054
+ teacher.load_state_dict(t_state, strict=False)
1055
+ teacher.eval().requires_grad_(False)
1056
+ self._put(f"[DISTILL] Teacher: {teacher_path}")
1057
+ trainer = ChronosDistillTrainer(model, teacher, model_cfg, stage_args, tokenizer)
1058
+ if float(getattr(model_cfg, "lambda_router_anchor", 0.0)) > 0 and loader is not None:
1059
+ first = next(iter(loader))
1060
+ trainer.set_calibration_batch(first[0])
1061
+ self._put("[DISTILL] Router anchor reference captured.")
1062
+ iters = len(loader) if stage_args.steps is None else min(stage_args.steps, len(loader))
1063
+ self.total_steps = max(1, stage_args.epochs * iters)
1064
+ for epoch in range(stage_args.epochs):
1065
+ epoch_start = time.monotonic()
1066
+ for step_idx, (ids, labels) in enumerate(loader, start=1):
1067
+ if self._stop.is_set():
1068
+ break
1069
+ if stage_args.steps is not None and step_idx > stage_args.steps:
1070
+ break
1071
+ result = trainer.train_step(ids, labels, epoch * iters + step_idx, self.total_steps)
1072
+ if step_idx % stage_args.log_interval == 0 or step_idx == iters:
1073
+ elapsed = max(time.monotonic() - epoch_start, 1e-6)
1074
+ self._record_stage_metric(mode, epoch * iters + step_idx, result, tps=step_idx / elapsed)
1075
+ if step_idx % stage_args.save_interval == 0:
1076
+ trainer._save(epoch, step_idx)
1077
+ self._generate_live_sample(model, device, cfg, mode)
1078
+ if self._stop.is_set() or stage_args.steps is not None:
1079
+ break
1080
+ trainer._save(epoch=stage_args.epochs - 1, step=iters)
1081
+ self._generate_live_sample(model, device, cfg, mode)
1082
+ elapsed = time.monotonic() - t_start
1083
+ self._put(f"[{mode.upper()}] Training complete in {elapsed:.1f}s. Saved → {ckp_path}")
1084
+ self.status = "finished"
1085
+ return
1086
+
1087
+ stop_outer = False
1088
+ for epoch in range(epochs):
1089
+ if self._stop.is_set() or stop_outer:
1090
+ break
1091
+ for step, (input_ids, labels) in enumerate(loader, start=1):
1092
+ if self._stop.is_set():
1093
+ break
1094
+ if max_steps and global_step >= max_steps:
1095
+ stop_outer = True
1096
+ break
1097
+ input_ids = input_ids.to(device)
1098
+ labels = labels.to(device)
1099
+
1100
+ out, lp = model(input_ids, labels=labels)
1101
+ moe_layers = [l.mlp for l in model.model.layers
1102
+ if isinstance(l.mlp, ChronosMOEFeedForward)]
1103
+
1104
+ aux_val = out.aux_loss.item() if hasattr(out, "aux_loss") else 0.0
1105
+
1106
+ # Use the shared loss helper so λ_temporal / λ_lookahead
1107
+ # actually flow gradient back to the gate. The previous
1108
+ # path passed the detached `last_router_probs` into
1109
+ # total_loss, which made the regularizers inert (the
1110
+ # gate received only CE gradient, so router-locality was
1111
+ # never learned even after thousands of steps).
1112
+ from chronos.trainer.loss_mixin import (
1113
+ chronos_loss_term, collect_router_probs,
1114
+ )
1115
+ loss = chronos_loss_term(
1116
+ model, out.loss, lp, model_cfg, aux_loss=out.aux_loss,
1117
+ )
1118
+ # Logging-only: detached probs are fine here.
1119
+ router_4d_det = collect_router_probs(model, with_grad=False)
1120
+ if router_4d_det is not None and router_4d_det.shape[1] > 1:
1121
+ from chronos.model.temporal_loss import temporal_locality_loss
1122
+ temp_val = temporal_locality_loss(
1123
+ router_4d_det.mean(dim=2)
1124
+ ).item()
1125
+ else:
1126
+ temp_val = 0.0
1127
+
1128
+ (loss / accum).backward()
1129
+ global_step += 1
1130
+ self.step = global_step
1131
+ self.loss = loss.item()
1132
+
1133
+ # LR schedule (warmup → cosine). Refresh every step.
1134
+ sched_total = self.total_steps or planned or (global_step + 1)
1135
+ apply_lr(optimizer, get_lr(global_step, sched_total, base_lr))
1136
+
1137
+ # Optimizer.step() once per `accum` micro-batches.
1138
+ # Off-by-one fix: trigger when (global_step % accum == 0).
1139
+ # Previous code did the same check but had no warmup-aware
1140
+ # accumulation count, occasionally stepping after step 1.
1141
+ if global_step % accum == 0:
1142
+ import torch.nn as nn
1143
+ nn.utils.clip_grad_norm_(model.parameters(), 1.0)
1144
+ optimizer.step()
1145
+ optimizer.zero_grad(set_to_none=True)
1146
+
1147
+ if global_step % log_interval == 0:
1148
+ now = time.monotonic()
1149
+ tps = log_interval / max(now - step_t, 1e-6)
1150
+ step_t = now
1151
+ ce_val = out.loss.item()
1152
+ tot_val = loss.item()
1153
+
1154
+ self._put(
1155
+ f"Epoch {epoch+1}/{epochs} Step {global_step} "
1156
+ f"loss={tot_val:.4f} ce={ce_val:.4f} "
1157
+ f"aux={aux_val:.4f} temporal={temp_val:.4f} "
1158
+ f"steps/s={tps:.2f} lr={optimizer.param_groups[0]['lr']:.2e}"
1159
+ )
1160
+ self._put_metric({
1161
+ "step": global_step,
1162
+ "total": tot_val,
1163
+ "ce": ce_val,
1164
+ "aux": aux_val,
1165
+ "temporal": temp_val,
1166
+ "tps": tps,
1167
+ })
1168
+
1169
+ if global_step % save_interval == 0:
1170
+ os.makedirs(save_dir, exist_ok=True)
1171
+ torch.save(
1172
+ {k: v.half().cpu() for k, v in model.state_dict().items()},
1173
+ ckp_path
1174
+ )
1175
+ self._put(f"Saved checkpoint → {ckp_path}")
1176
+
1177
+ # Validation eval + best-ckpt tracking.
1178
+ v = _eval_val()
1179
+ if v == v: # not NaN
1180
+ self._put(f"Validation loss: {v:.4f} (best={best_val:.4f})")
1181
+ self._put_metric({
1182
+ "step": global_step, "total": v, "ce": v,
1183
+ "aux": 0.0, "temporal": 0.0, "tps": 0.0,
1184
+ "val": True,
1185
+ "val_loss": v,
1186
+ })
1187
+ if v < best_val:
1188
+ best_val = v
1189
+ torch.save(
1190
+ {k: vv.half().cpu() for k, vv in model.state_dict().items()},
1191
+ best_path,
1192
+ )
1193
+ self._put(f" ↑ new best → {best_path}")
1194
+
1195
+ # M8a: live qualitative sample so the user sees
1196
+ # whether the model is actually learning, not just
1197
+ # whether the loss number went down. We greedy-decode
1198
+ # ~80 tokens from the user-supplied prompt, and fall
1199
+ # back to a stage-aware evaluation prompt if blank.
1200
+ try:
1201
+ sp = (cfg.get("sample_prompt") or "").strip()
1202
+ if not sp:
1203
+ sp = _stage_sample_prompt(mode)
1204
+ tokenizer = self._load_tokenizer()
1205
+ ids = tokenizer.encode(sp, return_tensors="pt").to(device)
1206
+ model.eval()
1207
+ with torch.no_grad():
1208
+ gen = ids.clone()
1209
+ for _ in range(80):
1210
+ o, _ = model(gen, use_cache=False)
1211
+ nxt = o.logits[:, -1, :].argmax(dim=-1, keepdim=True)
1212
+ gen = torch.cat([gen, nxt], dim=-1)
1213
+ if int(nxt.item()) == (tokenizer.eos_token_id or -1):
1214
+ break
1215
+ txt = tokenizer.decode(gen[0].tolist(), skip_special_tokens=True)
1216
+ model.train()
1217
+ self.last_sample = txt
1218
+ self.last_sample_step = global_step
1219
+ self._put(f"[sample @ step {global_step}] {txt[:200]}")
1220
+ except Exception as gen_err:
1221
+ self._put(f"[sample failed: {gen_err}]")
1222
+
1223
+ os.makedirs(save_dir, exist_ok=True)
1224
+ torch.save(
1225
+ {k: v.half().cpu() for k, v in model.state_dict().items()},
1226
+ ckp_path
1227
+ )
1228
+ elapsed = time.monotonic() - t_start
1229
+ self._put(f"Training complete in {elapsed:.1f}s. Saved → {ckp_path}")
1230
+ self.status = "finished"
1231
+
1232
+ except Exception as e:
1233
+ import traceback
1234
+ self._put(f"[ERROR] {e}\n{traceback.format_exc()}")
1235
+ self.status = "error"
1236
+
1237
+ def _synthetic_loader(self, vocab_size, seq_len, batch_size, n=50):
1238
+ import torch
1239
+ from torch.utils.data import TensorDataset, DataLoader
1240
+ ids = torch.randint(0, vocab_size, (n * batch_size, seq_len))
1241
+ ds = TensorDataset(ids, ids.clone())
1242
+ return DataLoader(ds, batch_size=batch_size)
1243
+
1244
+ def _load_tokenizer(self):
1245
+ from transformers import AutoTokenizer
1246
+ return AutoTokenizer.from_pretrained(chronos.deps.get_tokenizer_path())
1247
+
1248
+
1249
+ _session = TrainSession()
1250
+
1251
+
1252
+ def _fmt_eta(seconds: float) -> str:
1253
+ if seconds is None or seconds < 0 or seconds != seconds:
1254
+ return "—"
1255
+ if seconds < 60:
1256
+ return f"{seconds:.0f}s"
1257
+ if seconds < 3600:
1258
+ return f"{seconds/60:.1f}min"
1259
+ return f"{seconds/3600:.1f}h"
1260
+
1261
+
1262
+ def _make_loss_chart(metrics: list[dict], mode: str = "pretrain", total_steps: int = 0, t_start: float = 0.0):
1263
+ """Build matplotlib figure: loss curves + throughput + progress/ETA."""
1264
+ try:
1265
+ import matplotlib
1266
+ matplotlib.use("Agg")
1267
+ import matplotlib.pyplot as plt
1268
+ meta = STAGE_CHART_META.get(mode, STAGE_CHART_META["pretrain"])
1269
+
1270
+ # Close any prior figures held by this thread to avoid the
1271
+ # "More than 20 figures opened" warning Gradio's Plot component triggers.
1272
+ plt.close("all")
1273
+
1274
+ if not metrics:
1275
+ fig, ax = plt.subplots(figsize=(10, 3))
1276
+ ax.text(0.5, 0.5, "No data yet", ha="center", va="center",
1277
+ transform=ax.transAxes, fontsize=13, color="#888")
1278
+ ax.set_axis_off()
1279
+ plt.tight_layout()
1280
+ return fig
1281
+
1282
+ steps = [m["step"] for m in metrics]
1283
+ cur_step = steps[-1]
1284
+ elapsed = max(0.0, time.monotonic() - t_start) if t_start else 0.0
1285
+ # ETA from average step rate so far (not last point — smoother).
1286
+ rate = cur_step / elapsed if elapsed > 0 else 0.0
1287
+ remaining = (total_steps - cur_step) / rate if rate > 0 and total_steps else float("nan")
1288
+ progress_pct = min(100.0, 100.0 * cur_step / total_steps) if total_steps else 0.0
1289
+
1290
+ # M8b: split val markers from train metrics.
1291
+ train_metrics = [m for m in metrics if not m.get("val")]
1292
+ val_metrics = [m for m in metrics if m.get("val")]
1293
+ train_steps = [m["step"] for m in train_metrics]
1294
+ val_steps = [m["step"] for m in val_metrics]
1295
+
1296
+ fig, axes = plt.subplots(1, 3, figsize=(13, 3.5),
1297
+ gridspec_kw={"width_ratios": [4, 3, 3]})
1298
+ fig.patch.set_facecolor("#1a1a2e")
1299
+
1300
+ # ── Left: loss curves
1301
+ ax = axes[0]
1302
+ ax.set_facecolor("#16213e")
1303
+ if train_metrics:
1304
+ ax.plot(train_steps, [m["total"] for m in train_metrics], color="#e94560", lw=1.5, label="train total")
1305
+ ax.plot(train_steps, [m.get("primary", m.get("ce", 0.0)) for m in train_metrics],
1306
+ color="#0f3460", lw=1.2, label=meta["primary"])
1307
+ ax.plot(train_steps, [m.get("secondary", m.get("aux", 0.0)) for m in train_metrics],
1308
+ color="#533483", lw=1.0, label=meta["secondary"])
1309
+ ax.plot(train_steps, [m.get("tertiary", m.get("temporal", 0.0)) for m in train_metrics],
1310
+ color="#e2b714", lw=0.9, label=meta["tertiary"], ls="--")
1311
+ if val_metrics:
1312
+ ax.plot(val_steps, [m.get("primary", m.get("ce", 0.0)) for m in val_metrics], color="#39d98a",
1313
+ lw=2.0, marker="o", markersize=4, label=meta["val"])
1314
+ ax.set_title(meta["title"], color="white", fontsize=11)
1315
+ ax.set_xlabel("Step", color="#aaa")
1316
+ ax.tick_params(colors="#aaa")
1317
+ for spine in ax.spines.values():
1318
+ spine.set_edgecolor("#333")
1319
+ ax.legend(fontsize=8, facecolor="#16213e", labelcolor="white")
1320
+
1321
+ # ── Middle: stage-aware speed panel
1322
+ ax2 = axes[1]
1323
+ ax2.set_facecolor("#16213e")
1324
+ tps_steps = [m["step"] for m in train_metrics if m.get("tps", 0) > 0]
1325
+ tps_vals = [m["tps"] for m in train_metrics if m.get("tps", 0) > 0]
1326
+ overlay_ax = None
1327
+ if mode == "grpo":
1328
+ reward_steps = [m["step"] for m in train_metrics if "tertiary" in m]
1329
+ reward_vals = [m.get("tertiary", 0.0) for m in train_metrics if "tertiary" in m]
1330
+ if tps_steps:
1331
+ ax2.plot(
1332
+ tps_steps,
1333
+ tps_vals,
1334
+ color="#00b4d8",
1335
+ lw=1.6,
1336
+ label=meta.get("speed_label", "steps/s"),
1337
+ )
1338
+ ax2.fill_between(tps_steps, tps_vals, alpha=0.18, color="#00b4d8")
1339
+ if reward_steps:
1340
+ overlay_ax = ax2.twinx()
1341
+ overlay_ax.set_facecolor("none")
1342
+ overlay_ax.plot(
1343
+ reward_steps,
1344
+ reward_vals,
1345
+ color="#e2b714",
1346
+ lw=1.3,
1347
+ ls="--",
1348
+ label=meta.get("speed_overlay", meta["tertiary"]),
1349
+ )
1350
+ overlay_ax.tick_params(colors="#f1d36b")
1351
+ overlay_ax.set_ylabel(meta.get("speed_overlay", meta["tertiary"]), color="#f1d36b", fontsize=9)
1352
+ for spine in overlay_ax.spines.values():
1353
+ spine.set_edgecolor("#333")
1354
+ elif tps_steps:
1355
+ ax2.plot(
1356
+ tps_steps,
1357
+ tps_vals,
1358
+ color="#00b4d8",
1359
+ lw=1.5,
1360
+ label=meta.get("speed_label", "steps/s"),
1361
+ )
1362
+ ax2.fill_between(tps_steps, tps_vals, alpha=0.2, color="#00b4d8")
1363
+ ax2.set_title(meta.get("speed_title", "Training Speed (steps/s)"), color="white", fontsize=11)
1364
+ ax2.set_xlabel("Step", color="#aaa")
1365
+ ax2.set_ylabel(meta.get("speed_label", "steps/s"), color="#8ae9ff", fontsize=9)
1366
+ ax2.tick_params(colors="#aaa")
1367
+ for spine in ax2.spines.values():
1368
+ spine.set_edgecolor("#333")
1369
+ if tps_steps or overlay_ax is not None:
1370
+ lines, labels = ax2.get_legend_handles_labels()
1371
+ if overlay_ax is not None:
1372
+ extra_lines, extra_labels = overlay_ax.get_legend_handles_labels()
1373
+ lines += extra_lines
1374
+ labels += extra_labels
1375
+ if labels:
1376
+ ax2.legend(lines, labels, fontsize=8, facecolor="#16213e", labelcolor="white", loc="upper right")
1377
+
1378
+ # ── Right: progress bar + numeric panel
1379
+ ax3 = axes[2]
1380
+ ax3.set_facecolor("#16213e")
1381
+ # Horizontal bar
1382
+ bar_y = 0.65
1383
+ ax3.barh([bar_y], [100], height=0.18, color="#2a2a44", edgecolor="#444")
1384
+ ax3.barh([bar_y], [progress_pct], height=0.18, color="#39d98a")
1385
+ ax3.set_xlim(0, 100); ax3.set_ylim(0, 1)
1386
+ ax3.set_xticks([0, 25, 50, 75, 100])
1387
+ ax3.set_yticks([])
1388
+ ax3.tick_params(colors="#aaa")
1389
+ for spine in ax3.spines.values():
1390
+ spine.set_edgecolor("#333")
1391
+
1392
+ # Big text overlay
1393
+ ax3.text(50, 0.92, f"Progress {progress_pct:5.1f}%",
1394
+ ha="center", va="center", color="white", fontsize=12, weight="bold")
1395
+ eta_str = _fmt_eta(remaining)
1396
+ elapsed_str = _fmt_eta(elapsed)
1397
+ ax3.text(50, 0.42, f"step {cur_step:,} / {total_steps or '?':,}",
1398
+ ha="center", va="center", color="#cfd8e3", fontsize=11)
1399
+ ax3.text(50, 0.25, f"elapsed {elapsed_str} ETA {eta_str}",
1400
+ ha="center", va="center", color="#9aa6b2", fontsize=10)
1401
+ ax3.text(50, 0.10, f"~{rate:.2f} {meta.get('speed_label', 'steps/s')} avg",
1402
+ ha="center", va="center", color="#9aa6b2", fontsize=9)
1403
+ ax3.set_title("Progress & ETA", color="white", fontsize=11)
1404
+
1405
+ plt.tight_layout(pad=1.5)
1406
+ return fig
1407
+
1408
+ except Exception:
1409
+ return None
1410
+
1411
+
1412
+ def build_train_tab(config_state: gr.State, cfg_save_dir: gr.Textbox):
1413
+ with gr.Tab(t("tab.train")) as tab:
1414
+ register_translatable(tab, "tab.train")
1415
+
1416
+ # ── Train owns its dataset choice (moved out of Config tab) ─
1417
+ stage_help = gr.Markdown(_stage_text(STAGE_HELP_TEXT, "pretrain"))
1418
+ with gr.Row():
1419
+ data_path = gr.Textbox(
1420
+ label=t("config.data_path"),
1421
+ value=STAGE_DEFAULT_DATA["pretrain"],
1422
+ placeholder=STAGE_DEFAULT_DATA["pretrain"],
1423
+ scale=3,
1424
+ )
1425
+ register_translatable(data_path, "config.data_path")
1426
+ dataset_upload = gr.File(
1427
+ label=t("train.dataset_upload"),
1428
+ file_types=[".jsonl"], type="filepath", scale=2,
1429
+ )
1430
+ register_translatable(dataset_upload, "train.dataset_upload")
1431
+ dataset_upload.change(
1432
+ fn=lambda fp: fp or "",
1433
+ inputs=dataset_upload,
1434
+ outputs=data_path,
1435
+ )
1436
+
1437
+ # ── Mode + controls ───────────────────────────────────────
1438
+ with gr.Row():
1439
+ mode = gr.Radio(
1440
+ STAGE_UI_ORDER,
1441
+ value="pretrain", label=t("train.mode")
1442
+ )
1443
+ register_translatable(mode, "train.mode")
1444
+ train_backend = gr.Dropdown(
1445
+ choices=_train_backend_dropdown_choices(),
1446
+ value=_default_train_backend_value(),
1447
+ label=t("train.backend"),
1448
+ scale=1,
1449
+ )
1450
+ register_translatable(train_backend, "train.backend")
1451
+ status_box = gr.Textbox(
1452
+ value="idle", label=t("train.status"),
1453
+ interactive=False, scale=1,
1454
+ )
1455
+ register_translatable(status_box, "train.status")
1456
+
1457
+ # ── Init-weight / stage-specific extras ────────────────────
1458
+ with gr.Row():
1459
+ init_weight = gr.Textbox(
1460
+ label=t("train.init_weight"),
1461
+ placeholder=_stage_init_placeholder("pretrain"),
1462
+ scale=3,
1463
+ )
1464
+ register_translatable(init_weight, "train.init_weight")
1465
+ teacher_path = gr.Textbox(
1466
+ label=t("pipeline.teacher_path"),
1467
+ placeholder=_distill_teacher_placeholder(),
1468
+ visible=False,
1469
+ scale=2,
1470
+ )
1471
+ register_translatable(teacher_path, "pipeline.teacher_path")
1472
+
1473
+ with gr.Row():
1474
+ max_steps_in = gr.Number(
1475
+ value=0, precision=0,
1476
+ label=t("train.max_steps"), scale=1,
1477
+ )
1478
+ val_ratio_in = gr.Slider(
1479
+ 0.0, 0.5, value=0.05, step=0.01,
1480
+ label=t("train.val_ratio"), scale=2,
1481
+ )
1482
+ weight_decay_in = gr.Number(
1483
+ value=0.01, precision=4,
1484
+ label=t("train.weight_decay"), scale=1,
1485
+ )
1486
+ register_translatable(max_steps_in, "train.max_steps")
1487
+ register_translatable(val_ratio_in, "train.val_ratio")
1488
+ register_translatable(weight_decay_in, "train.weight_decay")
1489
+
1490
+ with gr.Row():
1491
+ reward_spec_in = gr.Textbox(
1492
+ value="toy",
1493
+ label="reward",
1494
+ placeholder="toy or lm:/path/to/reward-model",
1495
+ visible=False,
1496
+ scale=2,
1497
+ )
1498
+ grpo_temp_in = gr.Number(
1499
+ value=1.0, precision=3,
1500
+ label="temperature",
1501
+ visible=False,
1502
+ scale=1,
1503
+ )
1504
+ grpo_num_gen_in = gr.Number(
1505
+ value=4, precision=0,
1506
+ label="num_generations",
1507
+ visible=False,
1508
+ scale=1,
1509
+ )
1510
+
1511
+ with gr.Row():
1512
+ sample_prompt_in = gr.Textbox(
1513
+ value=_stage_sample_prompt("pretrain"),
1514
+ label=t("train.sample_prompt"),
1515
+ placeholder=_stage_sample_prompt("pretrain"),
1516
+ scale=4,
1517
+ )
1518
+ register_translatable(sample_prompt_in, "train.sample_prompt")
1519
+
1520
+ with gr.Row():
1521
+ start_btn = gr.Button(t("train.start"), variant="primary")
1522
+ stop_btn = gr.Button(t("train.stop"), variant="stop")
1523
+ clear_btn = gr.Button(t("train.clear_log"), variant="secondary")
1524
+ register_translatable(start_btn, "train.start")
1525
+ register_translatable(stop_btn, "train.stop")
1526
+ register_translatable(clear_btn, "train.clear_log")
1527
+
1528
+ # ── Metrics chart ─────────────────────────────────────────
1529
+ chart = gr.Plot(label=t("train.chart"), show_label=True)
1530
+ register_translatable(chart, "train.chart")
1531
+
1532
+ # ── Scalar readouts ───────────────────────────────────────
1533
+ with gr.Row():
1534
+ step_box = gr.Number(label=t("train.step"), value=0, interactive=False, precision=0)
1535
+ loss_box = gr.Number(label=STAGE_METRIC_LABELS["pretrain"][0], value=0.0, interactive=False, precision=4)
1536
+ ce_box = gr.Number(label=STAGE_METRIC_LABELS["pretrain"][1], value=0.0, interactive=False, precision=4)
1537
+ aux_box = gr.Number(label=STAGE_METRIC_LABELS["pretrain"][2], value=0.0, interactive=False, precision=4)
1538
+ tps_box = gr.Number(label=t("train.tps"), value=0.0, interactive=False, precision=2)
1539
+ register_translatable(step_box, "train.step")
1540
+ register_translatable(tps_box, "train.tps")
1541
+
1542
+ # ── Scrollable log ────────────────────────────────────────
1543
+ log_box = gr.Textbox(
1544
+ label=t("train.log"), lines=18, max_lines=30,
1545
+ interactive=False, autoscroll=True,
1546
+ )
1547
+ register_translatable(log_box, "train.log")
1548
+
1549
+ # ── Live generation sample (updated each save_interval) ───
1550
+ sample_box = gr.Textbox(
1551
+ label=t("train.sample_output"), lines=6,
1552
+ interactive=False, autoscroll=False,
1553
+ )
1554
+ register_translatable(sample_box, "train.sample_output")
1555
+
1556
+ # ── Callbacks ─────────────────────────────────────────────
1557
+
1558
+ def sync_stage_fields(train_mode, current_prompt, current_data_path, current_init_weight):
1559
+ current = (current_prompt or "").strip()
1560
+ default_upstream = STAGE_DEFAULT_INIT.get(train_mode, "")
1561
+ help_md = _stage_text(STAGE_HELP_TEXT, train_mode)
1562
+ data_default = STAGE_DEFAULT_DATA.get(train_mode, "")
1563
+ metric_labels = STAGE_METRIC_LABELS.get(train_mode, STAGE_METRIC_LABELS["pretrain"])
1564
+ init_placeholder = _stage_init_placeholder(train_mode)
1565
+ current_data = (current_data_path or "").strip()
1566
+ current_init = _normalize_stage_init_value(current_init_weight)
1567
+ data_value = current_data
1568
+ if (not current_data) or current_data in DEFAULT_STAGE_DATA_VALUES:
1569
+ data_value = data_default
1570
+ init_value = current_init
1571
+ teacher_visible = train_mode == "distill"
1572
+ reward_visible = train_mode == "grpo"
1573
+ if (not current) or current in DEFAULT_SAMPLE_PROMPT_VALUES:
1574
+ prompt_update = gr.update(
1575
+ value=_stage_sample_prompt(train_mode),
1576
+ placeholder=_stage_sample_prompt(train_mode),
1577
+ )
1578
+ else:
1579
+ prompt_update = gr.update(placeholder=_stage_sample_prompt(train_mode))
1580
+ return (
1581
+ prompt_update,
1582
+ gr.update(value=help_md),
1583
+ gr.update(value=data_value, placeholder=data_default),
1584
+ gr.update(value=init_value, placeholder=init_placeholder),
1585
+ gr.update(visible=teacher_visible, placeholder=_distill_teacher_placeholder()),
1586
+ gr.update(visible=reward_visible),
1587
+ gr.update(visible=reward_visible),
1588
+ gr.update(visible=reward_visible),
1589
+ gr.update(label=metric_labels[0]),
1590
+ gr.update(label=metric_labels[1]),
1591
+ gr.update(label=metric_labels[2]),
1592
+ )
1593
+
1594
+ def start_training(cfg, train_mode, selected_backend, dpath, iw, ms, vr, wd, reward_spec, grpo_temp, grpo_num_gen, teacher, sp):
1595
+ if _session.is_running():
1596
+ return "already running", 0, 0.0, 0.0, 0.0, 0.0, "Already running.\n", None, ""
1597
+ cfg = dict(cfg) if cfg else {}
1598
+ requested_backend = (selected_backend or "auto").strip().lower() or "auto"
1599
+ _, resolved_device = resolve_training_device(requested_backend)
1600
+ cfg["train_backend"] = requested_backend
1601
+ cfg["device"] = resolved_device
1602
+ cfg["data_path"] = dpath or cfg.get("data_path", "")
1603
+ cfg["init_weight"] = _normalize_stage_init_value(iw)
1604
+ cfg["max_steps"] = int(ms or 0)
1605
+ cfg["val_ratio"] = float(vr or 0.0)
1606
+ cfg["weight_decay"] = float(wd or 0.0)
1607
+ cfg["reward_spec"] = reward_spec or "toy"
1608
+ cfg["temperature"] = float(grpo_temp or 1.0)
1609
+ cfg["num_generations"] = int(grpo_num_gen or 4)
1610
+ cfg["teacher_path"] = teacher or ""
1611
+ cfg["sample_prompt"] = sp or ""
1612
+ _session.start(cfg, train_mode)
1613
+ return "running", 0, 0.0, 0.0, 0.0, 0.0, "Starting...\n", None, ""
1614
+
1615
+ def stop_training():
1616
+ _session.stop()
1617
+ return "stopped"
1618
+
1619
+ def clear_log():
1620
+ return ""
1621
+
1622
+ def poll(current_log: str, current_mode: str):
1623
+ new_lines = _session.drain_log()
1624
+ # Append new lines to existing log; keep last 500 lines to avoid unbounded growth
1625
+ if new_lines:
1626
+ combined = (current_log + "\n" + new_lines).lstrip("\n")
1627
+ else:
1628
+ combined = current_log
1629
+
1630
+ lines = combined.split("\n")
1631
+ if len(lines) > 500:
1632
+ combined = "\n".join(lines[-500:])
1633
+
1634
+ metrics = _session.get_metrics()
1635
+ fig = _make_loss_chart(metrics, current_mode, _session.total_steps, _session.t_start)
1636
+
1637
+ # Latest scalar values from last metric entry
1638
+ if metrics:
1639
+ last = metrics[-1]
1640
+ ce_v = last.get("primary", last.get("ce", 0.0))
1641
+ aux_v = last.get("secondary", last.get("aux", 0.0))
1642
+ tps_v = last.get("tps", 0.0)
1643
+ else:
1644
+ ce_v = aux_v = tps_v = 0.0
1645
+
1646
+ return (
1647
+ _session.status,
1648
+ _session.step or 0,
1649
+ _session.loss or 0.0,
1650
+ ce_v,
1651
+ aux_v,
1652
+ tps_v,
1653
+ combined,
1654
+ fig,
1655
+ _session.last_sample or "",
1656
+ )
1657
+
1658
+ mode.change(
1659
+ fn=sync_stage_fields,
1660
+ inputs=[mode, sample_prompt_in, data_path, init_weight],
1661
+ outputs=[
1662
+ sample_prompt_in, stage_help, data_path, init_weight,
1663
+ teacher_path, reward_spec_in, grpo_temp_in, grpo_num_gen_in,
1664
+ loss_box, ce_box, aux_box,
1665
+ ],
1666
+ )
1667
+
1668
+ start_btn.click(
1669
+ fn=start_training,
1670
+ inputs=[config_state, mode, train_backend, data_path, init_weight,
1671
+ max_steps_in, val_ratio_in, weight_decay_in,
1672
+ reward_spec_in, grpo_temp_in, grpo_num_gen_in, teacher_path,
1673
+ sample_prompt_in],
1674
+ outputs=[status_box, step_box, loss_box, ce_box, aux_box, tps_box,
1675
+ log_box, chart, sample_box],
1676
+ )
1677
+ stop_btn.click(fn=stop_training, outputs=[status_box])
1678
+ clear_btn.click(fn=clear_log, outputs=[log_box])
1679
+
1680
+ timer = gr.Timer(value=2.0)
1681
+ timer.tick(
1682
+ fn=poll,
1683
+ inputs=[log_box, mode],
1684
+ outputs=[status_box, step_box, loss_box, ce_box, aux_box, tps_box,
1685
+ log_box, chart, sample_box],
1686
+ )
1687
+
1688
+ return tab