HumboldtJoker commited on
Commit
4956690
Β·
verified Β·
1 Parent(s): ea74ace

Add training template: test_template.py

Browse files
Files changed (1) hide show
  1. training-template/test_template.py +549 -0
training-template/test_template.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Daimon Training Template β€” Validation Tests
4
+ =============================================
5
+
6
+ Run these BEFORE launching training to catch configuration issues early.
7
+ Each test is independent and reports PASS/FAIL.
8
+
9
+ Usage:
10
+ python3 /workspace/runpod-template/test_template.py
11
+
12
+ These tests run on a SINGLE GPU (no distributed launch needed).
13
+ """
14
+
15
+ import os
16
+ import sys
17
+ import json
18
+ import time
19
+ import traceback
20
+
21
+ RESULTS = []
22
+
23
+
24
+ def test(name):
25
+ """Decorator to register and run a test."""
26
+ def decorator(fn):
27
+ def wrapper():
28
+ try:
29
+ fn()
30
+ RESULTS.append(("PASS", name, None))
31
+ print(f" PASS: {name}")
32
+ except Exception as e:
33
+ RESULTS.append(("FAIL", name, str(e)))
34
+ print(f" FAIL: {name}")
35
+ print(f" {e}")
36
+ traceback.print_exc()
37
+ wrapper.__name__ = name
38
+ wrapper._test = True
39
+ return wrapper
40
+ return decorator
41
+
42
+
43
+ # ── Test 1: GPU with sufficient VRAM ──────────────────────────────────────
44
+
45
+ @test("GPU with >= 140GB VRAM is available")
46
+ def test_gpu_vram():
47
+ import torch
48
+ gpu_count = torch.cuda.device_count()
49
+ assert gpu_count >= 1, (
50
+ f"No GPUs detected. Need at least 1x H200 SXM 141GB."
51
+ )
52
+
53
+ # Find the GPU with the most VRAM
54
+ max_vram_gb = 0
55
+ for i in range(gpu_count):
56
+ name = torch.cuda.get_device_name(i)
57
+ mem_gb = torch.cuda.get_device_properties(i).total_memory / 1e9
58
+ max_vram_gb = max(max_vram_gb, mem_gb)
59
+ print(f" GPU {i}: {name}, {mem_gb:.1f} GB")
60
+
61
+ assert max_vram_gb >= 140, (
62
+ f"Largest GPU has {max_vram_gb:.1f} GB VRAM. Need >= 140 GB (H200 SXM). "
63
+ f"Full SFT needs ~90GB GPU (70GB model + 20GB activations)."
64
+ )
65
+
66
+
67
+ # ── Test 2: System RAM >= 180GB (critical for CPU offload) ───────────────
68
+
69
+ @test("System RAM >= 180GB for CPU-offloaded optimizer")
70
+ def test_system_ram():
71
+ """
72
+ Full-parameter SFT offloads gradients (~70GB) and Adafactor states (~35GB)
73
+ to CPU RAM. Without enough system RAM, training will OOM on the CPU side.
74
+ """
75
+ ram_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
76
+ ram_gb = ram_bytes / 1e9
77
+ print(f" System RAM: {ram_gb:.0f} GB")
78
+
79
+ assert ram_gb >= 180, (
80
+ f"System RAM is {ram_gb:.0f} GB. Need >= 180 GB. "
81
+ f"CPU-offloaded memory budget: gradients (~70GB) + Adafactor (~35GB) = ~105GB, "
82
+ f"plus OS and data loading overhead. "
83
+ f"AdamW would need ~280GB β€” that's why we use Adafactor."
84
+ )
85
+
86
+ # Warn if tight
87
+ if ram_gb < 200:
88
+ print(f" WARNING: {ram_gb:.0f}GB is tight. 200GB+ recommended.")
89
+ print(f" CPU budget: ~105GB for offloaded states + ~30GB overhead")
90
+
91
+
92
+ # ── Test 3: Full SFT config is valid (no LoRA) ──────────────────────────
93
+
94
+ @test("Full SFT config is valid (no LoRA)")
95
+ def test_sft_config():
96
+ import yaml
97
+
98
+ config_paths = [
99
+ "/workspace/runpod-template/train_daimon_config.yaml",
100
+ os.path.join(os.path.dirname(__file__), "train_daimon_config.yaml"),
101
+ ]
102
+ found = None
103
+ for p in config_paths:
104
+ if os.path.exists(p):
105
+ found = p
106
+ break
107
+
108
+ assert found is not None, (
109
+ f"Config not found. Looked in: {config_paths}"
110
+ )
111
+
112
+ with open(found) as f:
113
+ config = yaml.safe_load(f)
114
+
115
+ # Verify NO LoRA section β€” this is full-parameter SFT
116
+ assert "lora" not in config, (
117
+ "Config still has a 'lora' section. This template does full-parameter SFT β€” "
118
+ "remove the lora section entirely."
119
+ )
120
+
121
+ # Verify Adafactor optimizer is configured
122
+ optimizer = config.get("optimizer", "")
123
+ assert optimizer.lower() == "adafactor", (
124
+ f"Optimizer must be 'adafactor' for full SFT on single node. "
125
+ f"Got: '{optimizer}'. AdamW needs ~280GB CPU RAM β€” not viable."
126
+ )
127
+
128
+ # Verify DeepSpeed config is referenced
129
+ ds_config = config.get("deepspeed_config")
130
+ assert ds_config is not None, (
131
+ "Config must reference deepspeed_config for ZeRO-2 CPU offload. "
132
+ "Full-parameter SFT cannot fit without gradient sharding."
133
+ )
134
+
135
+ # Verify learning rate is appropriate for full SFT (not LoRA rate)
136
+ lr = config.get("learning_rate", 0)
137
+ assert lr <= 1e-4, (
138
+ f"Learning rate {lr} is too high for full SFT. "
139
+ f"LoRA uses 2e-4, but full SFT should be 1e-5 to 5e-6 to avoid "
140
+ f"destabilizing MoE routing gates."
141
+ )
142
+
143
+ # Verify model revision is pinned
144
+ revision = config.get("model_revision")
145
+ assert revision is not None and len(revision) >= 10, (
146
+ f"model_revision should be pinned to a specific commit hash. Got: {revision}"
147
+ )
148
+
149
+ # Verify bf16 is enabled
150
+ assert config.get("bf16") is True, "bf16 must be enabled"
151
+
152
+ # Verify save_total_limit is small (full checkpoints are ~70GB each)
153
+ save_limit = config.get("save_total_limit", 10)
154
+ assert save_limit <= 5, (
155
+ f"save_total_limit={save_limit} is too high for full SFT. "
156
+ f"Each checkpoint is ~70GB. Limit to 3-5 to avoid filling disk."
157
+ )
158
+
159
+ print(f" Config: {found}")
160
+ print(f" Optimizer: {optimizer}")
161
+ print(f" DeepSpeed: {ds_config}")
162
+ print(f" Learning rate: {lr}")
163
+ print(f" Model revision: {revision[:12]}...")
164
+ print(f" bf16: {config.get('bf16')}")
165
+ print(f" save_total_limit: {save_limit}")
166
+ print(f" Method: Full-parameter SFT (no LoRA)")
167
+
168
+
169
+ # ── Test 4: DeepSpeed ZeRO-2 config exists and is valid ──────────────────
170
+
171
+ @test("DeepSpeed ZeRO-2 config is valid")
172
+ def test_deepspeed_config():
173
+ ds_paths = [
174
+ "/workspace/runpod-template/ds_config_zero2.json",
175
+ os.path.join(os.path.dirname(__file__), "ds_config_zero2.json"),
176
+ ]
177
+ found = None
178
+ for p in ds_paths:
179
+ if os.path.exists(p):
180
+ found = p
181
+ break
182
+
183
+ assert found is not None, (
184
+ f"DeepSpeed config not found. Looked in: {ds_paths}"
185
+ )
186
+
187
+ with open(found) as f:
188
+ ds_config = json.load(f)
189
+
190
+ # Verify it's ZeRO Stage 2 (not 3)
191
+ stage = ds_config.get("zero_optimization", {}).get("stage")
192
+ assert stage == 2, (
193
+ f"DeepSpeed must be ZeRO Stage 2, got stage {stage}. "
194
+ f"Stage 2 shards gradients; Stage 3 shards params too (not needed for single GPU "
195
+ f"where the model fits in VRAM)."
196
+ )
197
+
198
+ # Verify optimizer offload to CPU
199
+ offload_opt = ds_config.get("zero_optimization", {}).get("offload_optimizer", {})
200
+ assert offload_opt.get("device") == "cpu", (
201
+ f"Optimizer must be offloaded to CPU. "
202
+ f"Adafactor states (~35GB) need to live in system RAM."
203
+ )
204
+
205
+ # Verify params stay on GPU (not offloaded)
206
+ offload_param = ds_config.get("zero_optimization", {}).get("offload_param", {})
207
+ param_device = offload_param.get("device", "none")
208
+ assert param_device == "none", (
209
+ f"Parameters should NOT be offloaded (device={param_device}). "
210
+ f"The model fits in GPU VRAM β€” offloading params to CPU would be slow."
211
+ )
212
+
213
+ # Verify NO optimizer configured in DeepSpeed (we handle Adafactor in the script)
214
+ assert "optimizer" not in ds_config, (
215
+ "DeepSpeed config should NOT have an optimizer section. "
216
+ "Adafactor is configured in the training script directly β€” "
217
+ "DeepSpeed's optimizer config conflicts with custom optimizers."
218
+ )
219
+
220
+ # Verify bf16 enabled
221
+ assert ds_config.get("bf16", {}).get("enabled") is True, "bf16 must be enabled in DeepSpeed config"
222
+
223
+ print(f" Config: {found}")
224
+ print(f" ZeRO Stage: {stage}")
225
+ print(f" Optimizer offload: CPU (pin_memory={offload_opt.get('pin_memory')})")
226
+ print(f" Param offload: none (stays on GPU)")
227
+ print(f" bf16: enabled")
228
+ print(f" No DeepSpeed optimizer block (Adafactor managed by script)")
229
+
230
+
231
+ # ── Test 5: Model architecture loads ─────────────────────────────────────
232
+
233
+ @test("Model architecture loads")
234
+ def test_model_loads():
235
+ import yaml
236
+ from transformers import AutoConfig, AutoTokenizer
237
+
238
+ # Read revision from config
239
+ config_paths = [
240
+ "/workspace/runpod-template/train_daimon_config.yaml",
241
+ os.path.join(os.path.dirname(__file__), "train_daimon_config.yaml"),
242
+ ]
243
+ revision = None
244
+ for p in config_paths:
245
+ if os.path.exists(p):
246
+ with open(p) as f:
247
+ cfg = yaml.safe_load(f)
248
+ revision = cfg.get("model_revision")
249
+ break
250
+
251
+ model_id = "Qwen/Qwen3.6-35B-A3B"
252
+ local_path = "/workspace/models/Qwen3.6-35B-A3B"
253
+
254
+ if os.path.isdir(local_path) and os.path.exists(f"{local_path}/config.json"):
255
+ source = local_path
256
+ else:
257
+ source = model_id
258
+
259
+ kwargs = {"trust_remote_code": True}
260
+ if revision and source == model_id:
261
+ kwargs["revision"] = revision
262
+
263
+ config = AutoConfig.from_pretrained(source, **kwargs)
264
+ print(f" Model: {source}")
265
+ print(f" Type: {config.model_type}")
266
+ print(f" Hidden: {config.hidden_size}")
267
+ print(f" Layers: {config.num_hidden_layers}")
268
+ print(f" Experts: {getattr(config, 'num_experts', 'N/A')}")
269
+
270
+ # Also verify tokenizer loads
271
+ tokenizer = AutoTokenizer.from_pretrained(source, **kwargs)
272
+ print(f" Vocab: {tokenizer.vocab_size}")
273
+
274
+
275
+ # ── Test 6: Training data loads and sequences are within bounds ────────────
276
+
277
+ @test("Training data loads with valid sequence lengths")
278
+ def test_data_loads():
279
+ import yaml
280
+ from datasets import load_from_disk
281
+ from transformers import AutoTokenizer
282
+
283
+ data_dir = "/workspace/daimon-data"
284
+ train_arrow = f"{data_dir}/train_arrow"
285
+
286
+ assert os.path.isdir(train_arrow), (
287
+ f"Training data not found at {train_arrow}. "
288
+ f"Run setup.sh first to download and prepare data."
289
+ )
290
+
291
+ train_ds = load_from_disk(train_arrow)
292
+ print(f" Train samples: {len(train_ds):,}")
293
+
294
+ # Check a sample
295
+ sample = train_ds[0]
296
+ assert "messages" in sample, f"Expected 'messages' key, got: {list(sample.keys())}"
297
+ assert len(sample["messages"]) >= 2, "Each sample needs at least 2 messages (user + assistant)"
298
+
299
+ # Verify sequence lengths against max_seq_length
300
+ model_id = "Qwen/Qwen3.6-35B-A3B"
301
+ local_path = "/workspace/models/Qwen3.6-35B-A3B"
302
+ source = local_path if os.path.isdir(local_path) else model_id
303
+
304
+ # Read revision from config
305
+ config_paths = [
306
+ "/workspace/runpod-template/train_daimon_config.yaml",
307
+ os.path.join(os.path.dirname(__file__), "train_daimon_config.yaml"),
308
+ ]
309
+ kwargs = {"trust_remote_code": True}
310
+ for p in config_paths:
311
+ if os.path.exists(p):
312
+ with open(p) as f:
313
+ cfg = yaml.safe_load(f)
314
+ revision = cfg.get("model_revision")
315
+ if revision and source == model_id:
316
+ kwargs["revision"] = revision
317
+ break
318
+
319
+ tokenizer = AutoTokenizer.from_pretrained(source, **kwargs)
320
+
321
+ max_seq_length = 4096 # From config (reduced for full SFT)
322
+ too_long = 0
323
+ max_found = 0
324
+
325
+ for i, example in enumerate(train_ds):
326
+ try:
327
+ text = tokenizer.apply_chat_template(
328
+ example["messages"], tokenize=False, add_generation_prompt=False
329
+ )
330
+ tokens = len(tokenizer.encode(text, add_special_tokens=False))
331
+ max_found = max(max_found, tokens)
332
+ if tokens > max_seq_length:
333
+ too_long += 1
334
+ except Exception:
335
+ pass
336
+
337
+ if i >= 100: # Check first 100 samples
338
+ break
339
+
340
+ print(f" Max tokens in sample: {max_found}")
341
+ print(f" Exceeding {max_seq_length}: {too_long}/{min(len(train_ds), 101)}")
342
+
343
+ if too_long > 0:
344
+ print(f" WARNING: {too_long} sequences exceed max_seq_length.")
345
+ print(f" The training script will pre-split these, but check your data.")
346
+
347
+
348
+ # ── Test 7: Persistent volume is mounted and writable ──────────────────────
349
+
350
+ @test("Persistent volume is mounted and writable")
351
+ def test_persistent_volume():
352
+ workspace = "/workspace"
353
+ assert os.path.isdir(workspace), "/workspace not found. Is the persistent volume mounted?"
354
+
355
+ # Check it's writable
356
+ test_file = os.path.join(workspace, ".daimon_write_test")
357
+ try:
358
+ with open(test_file, "w") as f:
359
+ f.write("test")
360
+ os.remove(test_file)
361
+ except PermissionError:
362
+ raise AssertionError("/workspace is not writable. Check volume permissions.")
363
+
364
+ # Check available space β€” full checkpoints are ~70GB each
365
+ import shutil
366
+ total, used, free = shutil.disk_usage(workspace)
367
+ free_gb = free / (1024**3)
368
+ total_gb = total / (1024**3)
369
+ print(f" Volume: {total_gb:.0f} GB total, {free_gb:.0f} GB free")
370
+
371
+ assert free_gb >= 200, (
372
+ f"Only {free_gb:.0f} GB free on /workspace. "
373
+ f"Need at least 200GB for model + full checkpoints (~70GB each, limit=3)."
374
+ )
375
+
376
+
377
+ # ── Test 8: Memory estimate for full SFT ────────────────────────────────
378
+
379
+ @test("Memory estimate: full SFT fits in GPU + CPU")
380
+ def test_memory_estimate():
381
+ """
382
+ Estimate memory usage for full-parameter SFT with Adafactor + ZeRO-2.
383
+ Verifies both GPU VRAM and system RAM are sufficient.
384
+ Does NOT load the full model β€” just calculates from config.
385
+ """
386
+ import torch
387
+
388
+ # Qwen3.6-35B-A3B has ~35B total params
389
+ total_params = 35e9
390
+
391
+ # GPU memory budget
392
+ model_gb = total_params * 2 / 1e9 # bf16 = 2 bytes per param = ~70GB
393
+ activation_gb = 20.0 # with gradient checkpointing
394
+ gpu_total = model_gb + activation_gb # ~90GB
395
+
396
+ # CPU memory budget (ZeRO-2 offloaded)
397
+ gradient_gb = total_params * 2 / 1e9 # bf16 gradients = ~70GB
398
+ # Adafactor: factored second moments, roughly 1 state per param in mixed precision
399
+ # Much less than AdamW's 2 fp32 states (280GB)
400
+ adafactor_gb = total_params * 1 / 1e9 # ~35GB (conservative estimate)
401
+ cpu_total = gradient_gb + adafactor_gb # ~105GB
402
+
403
+ # AdamW comparison (for reference)
404
+ adamw_gb = total_params * 4 * 2 / 1e9 # 2 fp32 states = ~280GB
405
+
406
+ # Available resources
407
+ gpu_vram_gb = torch.cuda.get_device_properties(0).total_memory / 1e9
408
+ ram_bytes = os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
409
+ system_ram_gb = ram_bytes / 1e9
410
+
411
+ print(f" === GPU Memory ===")
412
+ print(f" Model params (bf16): {model_gb:.1f} GB")
413
+ print(f" Activations (grad ckpt): {activation_gb:.1f} GB")
414
+ print(f" GPU total: {gpu_total:.1f} GB")
415
+ print(f" GPU available: {gpu_vram_gb:.1f} GB")
416
+ print(f" GPU headroom: {gpu_vram_gb - gpu_total:.1f} GB")
417
+ print(f" ")
418
+ print(f" === CPU Memory (offloaded) ===")
419
+ print(f" Gradients (bf16): {gradient_gb:.1f} GB")
420
+ print(f" Adafactor states: {adafactor_gb:.1f} GB")
421
+ print(f" CPU total: {cpu_total:.1f} GB")
422
+ print(f" System RAM: {system_ram_gb:.0f} GB")
423
+ print(f" CPU headroom: {system_ram_gb - cpu_total:.0f} GB")
424
+ print(f" ")
425
+ print(f" === Why NOT AdamW ===")
426
+ print(f" AdamW states would need: {adamw_gb:.0f} GB CPU RAM")
427
+ print(f" System RAM available: {system_ram_gb:.0f} GB")
428
+ print(f" Deficit: {adamw_gb - system_ram_gb:.0f} GB (does not fit)")
429
+
430
+ assert gpu_total < gpu_vram_gb, (
431
+ f"Estimated GPU usage ({gpu_total:.1f} GB) exceeds GPU capacity ({gpu_vram_gb:.1f} GB)."
432
+ )
433
+
434
+ assert cpu_total < system_ram_gb * 0.85, (
435
+ f"Estimated CPU usage ({cpu_total:.1f} GB) exceeds safe threshold "
436
+ f"({system_ram_gb * 0.85:.0f} GB = 85% of {system_ram_gb:.0f} GB). "
437
+ f"Need headroom for OS, data loading, and PyTorch buffers."
438
+ )
439
+
440
+
441
+ # ── Test 9: Smoke test β€” imports and config validation ────────────────────
442
+
443
+ @test("Training imports and config validation (smoke test)")
444
+ def test_smoke():
445
+ """
446
+ Verify all training imports work and the config is valid.
447
+ Does NOT load the full model β€” that would require too much VRAM for a test.
448
+ """
449
+ import torch
450
+ from transformers import AutoModelForCausalLM, AutoTokenizer, Adafactor
451
+
452
+ # Verify trainer imports (no peft needed)
453
+ print(f" Verifying trainer imports...")
454
+ from trl import SFTTrainer, SFTConfig
455
+ import deepspeed
456
+
457
+ # Verify Adafactor is importable
458
+ print(f" Adafactor: importable from transformers")
459
+
460
+ # Verify NO peft dependency
461
+ # (peft may be installed but should not be required)
462
+ print(f" No peft/LoRA dependency required for full SFT")
463
+
464
+ # Create a minimal SFTConfig to verify all parameters are accepted
465
+ test_config = SFTConfig(
466
+ output_dir="/tmp/daimon_test",
467
+ max_length=256,
468
+ num_train_epochs=1,
469
+ per_device_train_batch_size=1,
470
+ gradient_accumulation_steps=1,
471
+ learning_rate=5e-6,
472
+ max_steps=1,
473
+ bf16=True,
474
+ gradient_checkpointing=True,
475
+ gradient_checkpointing_kwargs={"use_reentrant": False},
476
+ report_to="none",
477
+ )
478
+
479
+ print(f" SFTConfig created successfully")
480
+ print(f" DeepSpeed version: {deepspeed.__version__}")
481
+ print(f" TRL version: {__import__('trl').__version__}")
482
+ print(f" Transformers version: {__import__('transformers').__version__}")
483
+
484
+ # Verify YAML config loads
485
+ import yaml
486
+ config_paths = [
487
+ "/workspace/runpod-template/train_daimon_config.yaml",
488
+ os.path.join(os.path.dirname(__file__), "train_daimon_config.yaml"),
489
+ ]
490
+ for p in config_paths:
491
+ if os.path.exists(p):
492
+ with open(p) as f:
493
+ cfg = yaml.safe_load(f)
494
+ print(f" YAML config loaded: {len(cfg)} keys")
495
+ break
496
+
497
+ # Clean up
498
+ import shutil
499
+ if os.path.exists("/tmp/daimon_test"):
500
+ shutil.rmtree("/tmp/daimon_test")
501
+
502
+ print(f" Smoke test passed.")
503
+
504
+
505
+ # ── Run all tests ──────────────────────────────────────────────────────────
506
+
507
+ def main():
508
+ print("=" * 60)
509
+ print(" DAIMON TRAINING TEMPLATE β€” VALIDATION TESTS")
510
+ print(" Method: Full-Parameter SFT (no LoRA)")
511
+ print(f" {time.strftime('%Y-%m-%dT%H:%M:%S')}")
512
+ print("=" * 60)
513
+ print()
514
+
515
+ # Collect all test functions
516
+ tests = [v for v in globals().values() if callable(v) and getattr(v, '_test', False)]
517
+
518
+ for test_fn in tests:
519
+ test_fn()
520
+ print()
521
+
522
+ # Summary
523
+ passed = sum(1 for r in RESULTS if r[0] == "PASS")
524
+ failed = sum(1 for r in RESULTS if r[0] == "FAIL")
525
+
526
+ print("=" * 60)
527
+ print(f" RESULTS: {passed} passed, {failed} failed")
528
+ print()
529
+
530
+ for status, name, error in RESULTS:
531
+ marker = "PASS" if status == "PASS" else "FAIL"
532
+ print(f" [{marker}] {name}")
533
+ if error:
534
+ print(f" {error}")
535
+
536
+ print()
537
+ if failed == 0:
538
+ print(" STATUS: ALL TESTS PASSED β€” READY TO TRAIN")
539
+ print(" Next: bash /workspace/runpod-template/launch.sh")
540
+ else:
541
+ print(" STATUS: FIX FAILURES BEFORE TRAINING")
542
+
543
+ print("=" * 60)
544
+
545
+ sys.exit(failed)
546
+
547
+
548
+ if __name__ == "__main__":
549
+ main()