File size: 28,137 Bytes
9f818c5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: OpenMDW-1.1

"""Self-contained regression test for the SFT smoke launch flow.

Re-runs the same ``torchrun`` invocation that ``launch_sft_llava_ov.sh``
executes (limited to 10 iterations, ``--deterministic`` mode) and asserts that
the rank-0 ``loss`` and global ``clip_grad_norm`` reproduce the inline goldens
at the bottom of this file. The launch goes through
``cosmos_framework.scripts.train --sft-toml=examples/toml/sft_config/<recipe>.toml``
β€” the only training entrypoint after the structured-TOML refactor.

Per-GPU goldens
---------------

Goldens are keyed by detected GPU architecture (``torch.cuda.get_device_name``):

* ``gb200`` β€” original values captured 2026-05-18 against the legacy
  ``cosmos_framework.scripts.train`` pipeline. The inputs and VLM backbone
  used at the time are not part of the OSS layout. The entries stay inline
  as a documented historical reference; don't re-run the GB200 path locally.
* ``h100`` β€” captured on 8Γ— H100 (4-GPU subset). The VLM backbone is
  ``Qwen/Qwen3-VL-8B-Instruct``. Input paths come from env vars matching the
  names in ``docs/training.md``::

      MODEL_PATH            VLM backbone (Qwen/Qwen3-VL-8B-Instruct local snapshot)

  Use ``tests/_stage_h100_inputs.sh`` to download/convert this and emit an
  ``env.sh`` that ``source``s ``MODEL_PATH`` before invoking pytest.

This file is intentionally the only deliverable β€” the goldens are embedded as a
Python constant and the ``torchrun`` command line is reproduced here, so the
upstream launch shell stays untouched and there is no separate JSON file to
commit.

Invocation (on a 4-GPU node, inside the training container, from the repo
root)::

    pytest -s tests/launch_regression_test.py --num-gpus=4 --levels=2 -o addopts=

* ``--num-gpus=4 --levels=2`` matches the markers on the test below and lets
  the conftest's per-test setup pin ``CUDA_VISIBLE_DEVICES=0,1,2,3`` for
  torchrun. (``4`` is in ``ALL_NUM_GPUS`` in
  ``cosmos_framework/inference/fixtures/args.py``.)
* ``-o addopts=`` clears the ``addopts`` line in the repo's ``.pytest.toml``
  which references ``--suppress-no-test-exit-code`` from the optional
  ``pytest-custom-exit-code`` plugin (not installed in the training image).

Determinism notes:
  * ``llava_ov_datapacker`` runs **without** ``--deterministic`` on H100 AND
    overrides ``model.config.deterministic=false``: the Qwen3-VL text
    path uses an attention backend whose Hopper FMHA backward kernel has no
    deterministic mode (raises ``NotImplementedError`` under PyTorch's
    deterministic context). ``VLMModel.__init__`` honors the config-level
    flag via ``init_flash_attn_meta`` independently of the launcher arg, so
    both must be off. It streams ``lmms-lab/LLaVA-OneVision-Data`` from the
    HuggingFace Hub with ``dataloader_train.num_workers=0`` so the data order is
    fully deterministic (single process); the only run-to-run noise left is the
    FMHA backward kernel. iter-0 is bit-exact (forward only) but iters 1+ drift
    (the Hopper FMHA backward has no deterministic mode β€” confirmed: forcing
    ``deterministic=true`` raises ``NotImplementedError``, and a 2-run
    ``num_workers=0`` check still drifts ≀0.006 on iters 1-9). All 10 iters are
    asserted with a tiered tolerance (``loss_tol_bands``): iter-0 at
    1e-3, iters 1-2 at 1e-2, iters 3-9 at 2e-2.

Refreshing the goldens (after an intentional numerical change)::

    COSMOS_REGRESSION_UPDATE_GOLDENS=1 pytest -s launch_regression_test.py ...

That prints the captured series for each spec; copy them into the matching
``_GOLDENS[<arch>]`` entry below.
"""

from __future__ import annotations

import os
import re
import shutil
import socket
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path

import pytest

from cosmos_framework.inference.fixtures.args import MAX_GPUS

THIS_DIR = Path(__file__).resolve().parent
# ``cosmos_framework.scripts.train`` and the ``--sft-toml=...`` paths are relative to
# the repo root; we always invoke torchrun from there.
REPO_ROOT = THIS_DIR.parent


def _free_port() -> int:
    """Return a currently-free TCP port for torchrun's rendezvous, instead of a
    hardcoded ``master_port`` that ``EADDRINUSE``s when a prior run lingers."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("", 0))
        return s.getsockname()[1]

# --- per-arch input paths ----------------------------------------------------
#
# GB200: the original input snapshot lived on an internal read-only filesystem
# that is not in the OSS layout, so the GB200 path is not runnable here. The
# GB200 goldens dict is kept as a historical reference; ``_resolve_paths``
# below skips the GB200 arch instead of re-running it.


def _hf_download(args: list[str]) -> str:
    """``uvx hf download <args> --quiet`` -> the local path it prints (from the HF cache)."""
    result = subprocess.run(
        ["uvx", "hf@latest", "download", *args, "--quiet"],
        cwd=str(REPO_ROOT),
        capture_output=True,
        text=True,
    )
    if result.returncode != 0:
        pytest.fail(f"hf download failed for {args} (exit {result.returncode}):\n{result.stdout}\n{result.stderr}")
    lines = [ln.strip() for ln in result.stdout.splitlines() if ln.strip()]
    if not lines:
        pytest.fail(f"hf download for {args} printed no path:\n{result.stdout}\n{result.stderr}")
    return lines[-1]


def _convert_nano_dcp(dest: Path) -> None:
    """Convert the Cosmos3-Nano checkpoint to DCP at ``dest`` (Step 2 of docs/training.md)."""
    env = os.environ.copy()
    env["PYTHONPATH"] = f".:{env.get('PYTHONPATH', '')}"
    result = subprocess.run(
        [
            sys.executable, "-m", "cosmos_framework.scripts.convert_model_to_dcp",
            "-o", str(dest), "--checkpoint-path", "Cosmos3-Nano",
        ],
        cwd=str(REPO_ROOT),
        env=env,
    )
    if result.returncode != 0:
        pytest.fail(f"convert_model_to_dcp (Cosmos3-Nano) failed with exit code {result.returncode}")


def _detect_arch() -> str:
    """Map ``torch.cuda.get_device_name(0)`` to a goldens key."""
    import torch  # local import keeps module import side-effects light

    if not torch.cuda.is_available():
        return "unknown"
    name = torch.cuda.get_device_name(0).upper()
    if "GB200" in name:
        return "gb200"
    # H200 shares the Hopper kernels with H100 and is treated identically here:
    # both map to the ``h100`` goldens key (the GitHub GPU CI runs on 8Γ—H200).
    if "H100" in name or "H200" in name:
        return "h100"
    return "unknown"


# Pinned revisions mirror tests/_stage_h100_inputs.sh so prepared inputs match
# the captured h100 goldens.
_BRIDGE_REVISION = "46468e12ac0dd36901e9e3240d4fc7620942b5d7"
_QWEN_VL_REVISION = "0c351dd01ed87e9c1b53cbc748cba10e6187ff3b"


# Tolerances for ``pytest.approx``. The launch passes ``--deterministic`` and
# ``PYTHONHASHSEED=42``; the tolerance only absorbs minor noise from
# non-deterministic NCCL reductions.
_DEFAULT_RTOL = 1e-3
_DEFAULT_ATOL = 1e-3

# --- log parsers -------------------------------------------------------------
#
# VLM (``pre_exp012_llava_ov_datapacker``) logs the DP-reduced loss on rank 0::
#
#     train/loss_avg: 1.32225 (iteration 0)
#
# ``GradClip`` emits the global grad-norm via every rank, prefixed with
# ``[RANK X]``. Key is ``clip_grad_norm/global`` for VLM.
_VLM_LOSS_RE = re.compile(r"train/loss_avg:\s+([0-9.eE+-]+)\s+\(iteration\s+\d+\)")
# VFM logs per-rank loss via the IterSpeed callback's on_training_step_end:
#     [RANK 0] Iteration 1: Hit counter: 1/50 | Loss: 0.2515 | Time: 120.42s
_VFM_LOSS_RE = re.compile(
    r"\[RANK\s+0\]\s+Iteration\s+\d+:\s+Hit counter:[^|]+\|\s+Loss:\s+([0-9.eE+-]+)"
)
_GRAD_NORM_RE = re.compile(
    r"\[RANK\s+0\][^\n]*clip_grad_norm/(?:[^/]+/)?global:\s+([0-9.eE+-]+)\s+\(iteration\s+\d+\)"
)


@dataclass(frozen=True)
class LaunchSpec:
    """A single launch flow under regression β€” mirrors the launcher shell."""

    key: str  # goldens key + pytest parametrize id source
    sft_toml: str  # ``--sft-toml=...`` value, relative to REPO_ROOT
    extra_hydra_args: tuple[str, ...]
    loss_re: re.Pattern[str]
    deterministic_iters: int  # how many leading iters are bit-exact deterministic
    extra_env: dict[str, str] = field(default_factory=dict)
    nproc_per_node: int = 4
    # Some specs can't run under ``--deterministic`` on H100: the Qwen3-VL text
    # attention's Hopper FMHA backward kernel has no deterministic mode and
    # raises NotImplementedError. For those specs we drop the flag and accept
    # the tighter goldens tolerance only on the iters that still reproduce in
    # practice (see ``deterministic_iters``).
    deterministic: bool = True
    # Per-spec goldens tolerance for ``pytest.approx``. Deterministic specs use
    # the tight default uniformly across all asserted iters.
    loss_rtol: float = _DEFAULT_RTOL
    loss_atol: float = _DEFAULT_ATOL
    # Optional tiered tolerance: each ``(count, rtol, atol)`` applies to the next
    # ``count`` iters in order, and the counts must sum to ``deterministic_iters``.
    # Lets the reasoner tighten its bit-exact iter-0 while loosening the
    # non-deterministic tail. When empty, all iters use ``loss_rtol/loss_atol``.
    loss_tol_bands: tuple[tuple[int, float, float], ...] = ()


# 4-GPU specs run by ``test_launch_regression``; 8-GPU specs run by
# ``test_launch_regression_8gpu`` (the ``gpus`` marker carries only one value,
# so the test functions are split).
_SPEC_KEYS = (
    "llava_ov_datapacker",
    "vision_sft_nano",
)
_SPEC_KEYS_8GPU = ("vision_sft_super",)


def _build_specs(paths: dict[str, str]) -> dict[str, LaunchSpec]:
    """Build the per-arch ``LaunchSpec`` list using the resolved input paths."""
    # vision_sft_super needs a Cosmos3-Super DCP; the default staging script
    # only produces Cosmos3-Nano. If BASE_CHECKPOINT_PATH_SUPER is set,
    # redirect BASE_CHECKPOINT_PATH for this spec via extra_env.
    super_extra_env: dict[str, str] = {}
    if super_ckpt := os.environ.get("BASE_CHECKPOINT_PATH_SUPER"):
        super_extra_env["BASE_CHECKPOINT_PATH"] = super_ckpt

    return {
        "llava_ov_datapacker": LaunchSpec(
            # Replicates launch_sft_llava_ov.sh, capped to 10 iters.
            key="llava_ov_datapacker",
            sft_toml="examples/toml/sft_config/llava_ov_datapacker.toml",
            extra_hydra_args=(
                # TAIL_OVERRIDES from launch_sft_llava_ov.sh β€” fields not modeled
                # by SFTExperimentConfig.
                f"model.config.policy.backbone.model_name={paths['vlm_model_path']}",
                "data_setting.max_tokens=16000",
                # 4-GPU subset for the test (TOML pins dp_shard=8 for the 8-GPU
                # launch shell).
                "model.config.parallelism.data_parallel_shard_degree=4",
                # The Hopper FMHA backward raises under PyTorch
                # deterministic mode, so both the config default and the
                # launcher's --deterministic flag must be off (see the
                # determinism notes in the module docstring).
                "model.config.deterministic=false",
                # num_workers=0: fully-ordered single-process streaming, so the
                # only run-to-run noise is the FMHA backward kernel, not data
                # order. prefetch_factor/persistent_workers must be unset for
                # num_workers=0 (torch DataLoader rejects them otherwise).
                "dataloader_train.num_workers=0",
                "dataloader_train.prefetch_factor=null",
                "dataloader_train.persistent_workers=false",
                # Regression-specific tweaks.
                "trainer.max_iter=10",
                "trainer.logging_iter=1",
                "job.wandb_mode=disabled",
                "ckpt_type=dummy",
                "checkpoint.load_from_object_store.enabled=false",
                "checkpoint.save_to_object_store.enabled=false",
                "upload_reproducible_setup=false",
            ),
            loss_re=_VLM_LOSS_RE,
            deterministic_iters=10,
            deterministic=False,
            # Tiered tolerance: iter-0 is bit-exact (forward only) β†’ 1e-3; iters
            # 1+ carry the FMHA-backward noise (≀0.006 across two num_workers=0
            # runs) β†’ 1e-2 for the early iters 1-2, 2e-2 for the tail 3-9.
            loss_tol_bands=(
                (1, 1e-3, 1e-3),  # iter 0
                (2, 1e-2, 1e-2),  # iters 1-2
                (7, 2e-2, 2e-2),  # iters 3-9
            ),
        ),
        "vision_sft_nano": LaunchSpec(
            # Replicates launch_sft_vision_nano.sh, capped to 10 iters.
            # ``DATASET_PATH`` / ``WAN_VAE_PATH`` / ``BASE_CHECKPOINT_PATH`` flow
            # in via the TOML's ``${oc.env:...}`` interpolation; no Hydra plumbing
            # needed beyond the regression-cap overrides below.
            key="vision_sft_nano",
            sft_toml="examples/toml/sft_config/vision_sft_nano.toml",
            extra_hydra_args=(
                "model.config.parallelism.data_parallel_shard_degree=4",
                "model.config.compile.enabled=true",
                "trainer.max_iter=10",
                "trainer.logging_iter=1",
                "job.wandb_mode=disabled",
                "upload_reproducible_setup=false",
                "checkpoint.save_iter=999999",
            ),
            loss_re=_VFM_LOSS_RE,
            deterministic_iters=10,
        ),
        "vision_sft_super": LaunchSpec(
            # Replicates launch_sft_vision_super.sh on 8 GPUs (dp_shard=4 Γ— cp=2),
            # capped to 10 iters. ``compile.enabled=false`` because the Super
            # backbone's compile path is not bit-exact across runs on H100.
            key="vision_sft_super",
            sft_toml="examples/toml/sft_config/vision_sft_super.toml",
            nproc_per_node=8,
            extra_hydra_args=(
                "model.config.parallelism.data_parallel_shard_degree=4",
                "model.config.parallelism.context_parallel_shard_degree=2",
                "model.config.compile.enabled=false",
                "trainer.max_iter=10",
                "trainer.logging_iter=1",
                "job.wandb_mode=disabled",
                "upload_reproducible_setup=false",
                "checkpoint.save_iter=999999",
            ),
            loss_re=_VFM_LOSS_RE,
            deterministic_iters=10,
            extra_env=super_extra_env,
        ),
    }


# --- helpers -----------------------------------------------------------------


def _parse_series(log_text: str, loss_re: re.Pattern[str]) -> tuple[list[float], list[float]]:
    """Extract per-iteration rank-0 loss and global grad-norm series, in order."""
    losses = [float(m.group(1)) for m in loss_re.finditer(log_text)]
    grad_norms = [float(m.group(1)) for m in _GRAD_NORM_RE.finditer(log_text)]
    assert losses and grad_norms, (
        f"No loss/grad-norm pairs found in log (losses={len(losses)}, grads={len(grad_norms)})"
    )
    assert len(losses) == len(grad_norms), (
        f"loss vs grad-norm length mismatch ({len(losses)} vs {len(grad_norms)}): "
        "the log must contain one rank-0 entry of each per training step."
    )
    return losses, grad_norms


def _run_torchrun(spec: LaunchSpec, run_dir: Path) -> Path:
    """Invoke the same ``torchrun`` command that the launcher shell runs.

    Returns the path of the captured combined stdout+stderr log.
    """
    run_dir.mkdir(parents=True, exist_ok=True)
    log_file = run_dir / "training.log"

    cmd = [
        "torchrun",
        f"--nproc_per_node={spec.nproc_per_node}",
        f"--master_port={_free_port()}",
        "-m",
        "cosmos_framework.scripts.train",
        f"--sft-toml={spec.sft_toml}",
    ]
    if spec.deterministic:
        cmd.append("--deterministic")
    cmd += ["--", *spec.extra_hydra_args]

    env = os.environ.copy()
    # HF env mirrors what the launcher shell sets up; ``HF_TOKEN`` must already
    # be exported in the caller's environment if the experiment hits gated Hub
    # endpoints (e.g. the LLaVA-OneVision-Data streaming dataset).
    env.setdefault("HF_HOME", "/tmp/hf_cache")
    Path(env["HF_HOME"]).mkdir(parents=True, exist_ok=True)
    env.setdefault("HF_HUB_DISABLE_XET", "1")
    env["PYTHONHASHSEED"] = "42"  # must be set before interpreter starts
    env["PYTHONPATH"] = f".:{env.get('PYTHONPATH', '')}"
    env["IMAGINAIRE_OUTPUT_ROOT"] = str(run_dir / "output")
    env.update(spec.extra_env)

    # Tee: stream the torchrun output live to stdout (so CI shows training
    # progress under ``pytest -s``) while capturing it into the log file.
    with log_file.open("w") as fp:
        proc = subprocess.Popen(
            cmd,
            env=env,
            cwd=str(REPO_ROOT),
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            text=True,
            bufsize=1,
        )
        assert proc.stdout is not None
        for line in proc.stdout:
            sys.stdout.write(line)
            sys.stdout.flush()
            fp.write(line)
        returncode = proc.wait()
    if returncode != 0:
        # Tolerate harmless PyGIL teardown warnings if training did complete.
        text = log_file.read_text(errors="replace")
        if "Done with training" not in text:
            pytest.fail(
                f"{spec.key}: torchrun failed with exit code {returncode} "
                "and log does not contain 'Done with training'.\n"
                f"Log tail:\n{text[-2000:]}"
            )
    return log_file


# --- fixtures ----------------------------------------------------------------


@pytest.fixture(scope="module", autouse=True)
def _require_4_gpus() -> None:
    """Skip the whole module unless we can launch 4-GPU training here."""
    if shutil.which("torchrun") is None:
        pytest.skip("torchrun not on PATH β€” must run inside the training container")
    try:
        import torch
    except Exception as exc:  # pragma: no cover β€” surfaces during dev only
        pytest.skip(f"torch unavailable ({exc!r})")
    if not torch.cuda.is_available() or torch.cuda.device_count() < 4:
        pytest.skip(f"requires 4 visible CUDA devices, found {torch.cuda.device_count()}")


@pytest.fixture(scope="module")
def h100_inputs(tmp_path_factory: pytest.TempPathFactory):
    """Provide the regression input paths, preparing any not already set in env.

    Mirrors the download/convert steps of ``tests/_stage_h100_inputs.sh`` (it
    does NOT set up the environment -- ``uv sync`` and the ``transformers``
    pin still belong to that script / the caller). Honors pre-set env vars (so
    ``source env.sh`` still works); anything prepared here goes under a temp
    stage dir that is removed on teardown. The four vars are exported because
    the SFT TOMLs interpolate ``DATASET_PATH`` / ``WAN_VAE_PATH`` /
    ``BASE_CHECKPOINT_PATH`` at load time and the VLM spec passes ``MODEL_PATH``
    as a Hydra backbone override.
    """
    arch = _detect_arch()
    if arch not in ("h100", "gb200"):
        pytest.skip(f"no regression goldens for GPU arch {arch!r}; only h100/gb200 supported")
    if shutil.which("uvx") is None:
        pytest.skip("uvx not on PATH -- required to prepare regression inputs")

    stage = tmp_path_factory.mktemp("h100_stage")
    set_vars: list[str] = []

    def _ensure(var: str, value_fn) -> None:
        if not os.environ.get(var):
            os.environ[var] = str(value_fn())
            set_vars.append(var)

    _ensure(
        "DATASET_PATH",
        lambda: Path(
            _hf_download(
                ["--repo-type", "dataset", "nvidia/bridge-v2-subset-synthetic-captions",
                 "--revision", _BRIDGE_REVISION]
            )
        ) / "sft_dataset_bridge",
    )
    _ensure("WAN_VAE_PATH", lambda: _hf_download(["Wan-AI/Wan2.2-TI2V-5B", "Wan2.2_VAE.pth"]))
    _ensure("MODEL_PATH", lambda: _hf_download(["Qwen/Qwen3-VL-8B-Instruct", "--revision", _QWEN_VL_REVISION]))

    def _make_dcp() -> Path:
        dest = stage / "Cosmos3-Nano-DCP"
        _convert_nano_dcp(dest)
        return dest

    _ensure("BASE_CHECKPOINT_PATH", _make_dcp)

    try:
        yield {"vlm_model_path": os.environ.get("MODEL_PATH", "")}
    finally:
        for var in set_vars:
            os.environ.pop(var, None)
        shutil.rmtree(stage, ignore_errors=True)


# --- tests -------------------------------------------------------------------


def _assert_spec_matches_goldens(spec_key: str, tmp_path: Path, paths: dict[str, str]) -> None:
    """Re-run ``spec``'s torchrun command and check loss / grad-norm against goldens."""
    arch = _detect_arch()
    spec = _build_specs(paths)[spec_key]

    log_path = _run_torchrun(spec, tmp_path)
    log_text = log_path.read_text(errors="replace")
    loss, grad_norm = _parse_series(log_text, spec.loss_re)
    # The run log also streamed live under ``pytest -s``; include its tail in any
    # failure message so the run detail is attached to the failure report too.
    run_detail = f"\n--- {spec.key} run log (last 4000 chars) ---\n{log_text[-4000:]}"
    assert len(loss) == 10, f"expected 10 iterations, parsed {len(loss)} (loss={loss}){run_detail}"

    # Refresh path: print captured values for manual copy into ``_GOLDENS``.
    if os.environ.get("COSMOS_REGRESSION_UPDATE_GOLDENS") == "1":
        print(f"\n# --- goldens for arch={arch!r} key={spec.key!r} ---")
        print(f'"{spec.key}": {{')
        print(f'    "loss": {loss},')
        print(f'    "grad_norm": {grad_norm},')
        print("},")
        pytest.skip(
            f"captured fresh series for arch={arch!r} key={spec.key!r}; copy the printed "
            f"dict into _GOLDENS[{arch!r}] at the bottom of launch_regression_test.py, "
            "then rerun without COSMOS_REGRESSION_UPDATE_GOLDENS to assert."
        )

    arch_goldens = _GOLDENS.get(arch)
    assert arch_goldens is not None, (
        f"no goldens table for arch {arch!r}; capture with COSMOS_REGRESSION_UPDATE_GOLDENS=1"
    )
    expected = arch_goldens.get(spec.key)
    assert expected is not None, (
        f"no goldens for arch={arch!r} key={spec.key!r}; capture with COSMOS_REGRESSION_UPDATE_GOLDENS=1"
    )

    n = spec.deterministic_iters

    # Build the per-iter tolerance segments: either the spec's tiered bands or a
    # single uniform band spanning all asserted iters.
    if spec.loss_tol_bands:
        assert sum(c for c, _, _ in spec.loss_tol_bands) == n, (
            f"{spec.key}: loss_tol_bands counts {[c for c, _, _ in spec.loss_tol_bands]} "
            f"must sum to deterministic_iters={n}"
        )
        bands = spec.loss_tol_bands
    else:
        bands = ((n, spec.loss_rtol, spec.loss_atol),)

    start = 0
    for count, rtol, atol in bands:
        end = start + count
        assert loss[start:end] == pytest.approx(
            expected["loss"][start:end], rel=rtol, abs=atol
        ), (
            f"{spec.key} ({arch}): rank-0 loss[{start}:{end}] (rel/abs={rtol}) "
            f"does not match goldens\n"
            f"  got     : {loss[start:end]}\n"
            f"  expected: {expected['loss'][start:end]}{run_detail}"
        )
        start = end
    # ``grad_norm`` is optional: ``None`` skips the check when the FSDP
    # global-norm all-reduce isn't bit-exact on this arch.
    if expected["grad_norm"] is None:
        return
    assert grad_norm[:n] == pytest.approx(
        expected["grad_norm"][:n], rel=spec.loss_rtol, abs=spec.loss_atol
    ), (
        f"{spec.key} ({arch}): global grad-norm[:{n}] does not match goldens\n"
        f"  got     : {grad_norm[:n]}\n"
        f"  expected: {expected['grad_norm'][:n]}{run_detail}"
    )


# Define only the test function matching MAX_GPUS β€” the conftest rejects
# ``gpus(N)`` markers outside the active ``ALL_NUM_GPUS = (0, 1, MAX_GPUS)``.
if MAX_GPUS == 4:

    @pytest.mark.level(2)
    @pytest.mark.gpus(4)
    @pytest.mark.parametrize("spec_key", _SPEC_KEYS, ids=lambda k: k.removeprefix("launch_"))
    def test_launch_regression(spec_key: str, tmp_path: Path, h100_inputs: dict[str, str]) -> None:
        """Re-run ``spec``'s torchrun command and check loss / grad-norm against goldens."""
        _assert_spec_matches_goldens(spec_key, tmp_path, h100_inputs)


if MAX_GPUS == 8:

    @pytest.mark.skip(reason="vision_sft_super spec disabled")
    @pytest.mark.level(2)
    @pytest.mark.gpus(8)
    @pytest.mark.parametrize(
        "spec_key", _SPEC_KEYS_8GPU, ids=lambda k: k.removeprefix("launch_")
    )
    def test_launch_regression_8gpu(spec_key: str, tmp_path: Path, h100_inputs: dict[str, str]) -> None:
        """8-GPU variant for ``vision_sft_super`` (dp_shard=4 Γ— cp=2)."""
        _assert_spec_matches_goldens(spec_key, tmp_path, h100_inputs)


# Goldens keyed by GPU arch then ``LaunchSpec.key``. Refresh with
# ``COSMOS_REGRESSION_UPDATE_GOLDENS=1``.
_GOLDENS: dict[str, dict[str, dict[str, list[float] | None]]] = {
    # Captured 2026-05-18 on a 4 Γ— NVIDIA GB200 node with ``--deterministic``
    # and seed 42 against the legacy training pipeline. VLM backbone is not
    # part of the OSS layout.
    "gb200": {
        "llava_ov_datapacker": {
            "loss": [1.32208, 1.20886, 1.39254, 1.40460, 1.16652, 1.24852, 1.38463, 1.22766, 0.96263, 1.14468],
            "grad_norm": [
                38.62454, 23.61477, 30.53218, 36.46255, 25.06240,
                39.70305, 48.52226, 52.18334, 22.77521, 25.06970,
            ],
        },
        # Captured 2026-06-09 on a 4 Γ— NVIDIA GB200 node with seed 42 against the
        # current TOML-config pipeline (inputs prepared in-test by ``h100_inputs``,
        # which now also serves gb200). Runs under ``--deterministic`` so loss
        # reproduces bit-exact across all 10 iters; loss matches the h100 nano
        # series within ~1e-3. grad_norm is non-det because ``compile.enabled=true``
        # makes the all-rank reduction not bit-exact, so None (same as h100).
        "vision_sft_nano": {
            "loss": [0.2269, 0.2181, 0.2026, 0.2309, 0.2178, 0.273, 0.2871, 0.2164, 0.2059, 0.264],
            "grad_norm": None,
        },
    },
    # Recaptured 2026-06-03 on a 4 Γ— NVIDIA H100 80GB HBM3 node with seed 42 and
    # transformers==4.57.6. VLM model is ``Qwen/Qwen3-VL-8B-Instruct``; inputs are
    # prepared in-test by the ``h100_inputs`` fixture (or via
    # ``tests/_stage_h100_inputs.sh`` if its env vars are pre-set).
    "h100": {
        # num_workers=0, deterministic mode off (see the spec's hydra overrides
        # and the loss_tol_bands tiers). Centered on the midpoint of two H200 CI
        # runs (CI runs on H200) so the tiered bands keep maximum margin; iter-0
        # is bit-exact across H100/H200 runs. grad-norm is non-det, so None.
        "llava_ov_datapacker": {
            "loss": [1.06924, 0.88399, 1.09293, 1.16314, 1.03592, 0.99041, 1.11041, 0.97001, 0.81246, 0.98548],
            "grad_norm": None,
        },
        # Recaptured 2026-06-03 after the TOML-config rewrite shifted some
        # defaults. Runs under ``--deterministic`` so loss reproduces bit-exact
        # across all 10 iters, but grad_norm is non-det because
        # ``compile.enabled=true`` makes the all-rank reduction not bit-exact
        # on H100.
        "vision_sft_nano": {
            "loss": [0.2272, 0.2181, 0.2028, 0.2306, 0.218, 0.2734, 0.2865, 0.2162, 0.2055, 0.2643],
            "grad_norm": None,
        },
        "vision_sft_super": {
            "loss": [0.2133, 0.2028, 0.1992, 0.2373, 0.2539, 0.2645, 0.2679, 0.2182, 0.1959, 0.2457],
            "grad_norm": [0.00403, 0.00255, 0.00412, 0.00485, 0.00305, 0.00331, 0.00375, 0.00371, 0.00313, 0.00276],
        },
    },
}


if __name__ == "__main__":  # pragma: no cover β€” manual driver
    sys.exit(pytest.main([__file__, "-v", "-s", "-o", "addopts="]))