File size: 8,471 Bytes
201dc77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""DPO loss adapter: batch plumbing, metric contract, ref-logprob refusal.

Run: uv run pytest tests/test_dpo_loss.py -m "not gpu" -v
(venv equivalent on this box: .venv/bin/python -m pytest tests/test_dpo_loss.py -m "not gpu" -v)

The kernel's own tests (`test_fused_dpo.py`) cover the objective. This file
covers the layer between the Trainer and the kernel: unpacking a paired batch,
the caller-side label shift, the mandatory-metric contract, and the refusals
that must not be silent.
"""

from __future__ import annotations

import math

import pytest
import torch

from align.config import ModelConfig
from align.kernels.fused_dpo import MANDATORY_METRIC_KEYS
from align.train.dpo_loss import (
    IGNORE_INDEX,
    DpoLossConfig,
    fused_dpo_available,
    make_dpo_loss_adapter,
)


def tiny_model(consts: dict):
    from align.model.mistral import NmoMistralForCausalLM

    cfg = ModelConfig(
        name="tiny-mistral-dpo",
        n_layers=consts["n_layers"],
        hidden_size=consts["hidden_size"],
        intermediate_size=consts["intermediate_size"],
        n_heads=consts["n_heads"],
        n_kv_heads=consts["n_kv_heads"],
        head_dim=consts["head_dim"],
        vocab_size=consts["vocab_size"],
        max_position_embeddings=consts["max_seq_len"],
        rope_theta=consts["rope_theta"],
        precision="fp32",
    )
    cfg.n_params = -1
    torch.manual_seed(consts["seed"])
    return NmoMistralForCausalLM(cfg, attn_implementation="eager"), cfg


def paired_batch(cfg, pairs=3, seq=9, prompt_len=3, seed=0):
    g = torch.Generator().manual_seed(seed)
    out = {}
    for side in ("chosen", "rejected"):
        ids = torch.randint(0, cfg.vocab_size, (pairs, seq), generator=g)
        labels = ids.clone()
        labels[:, :prompt_len] = IGNORE_INDEX
        out[f"input_ids_{side}"] = ids
        out[f"labels_{side}"] = labels
    out["ref_logp_chosen"] = torch.randn(pairs, generator=g) - 20.0
    out["ref_logp_rejected"] = torch.randn(pairs, generator=g) - 22.0
    return out


def test_adapter_returns_loss_output_with_mandatory_metrics(tiny_model_constants):
    """The Trainer reads `loss`, `n_tokens` and `metrics` off a LossOutput, and
    section-12's dashboard reads the mandatory metric keys off `metrics` by
    name. Both are checked here so a rename fails at the adapter rather than
    at analysis time."""
    model, cfg = tiny_model(tiny_model_constants)
    batch = paired_batch(cfg, seed=1)
    out = make_dpo_loss_adapter()(model, batch)

    assert torch.isfinite(out.loss) and out.loss.dim() == 0
    for key in MANDATORY_METRIC_KEYS:
        assert key in out.metrics, key
        assert not math.isnan(out.metrics[key]), key
    # 3 pairs x (9 - 1 shifted - 3 prompt + 1) scored tokens on each side.
    expected = 2 * 3 * (9 - 1 - 3 + 1)
    assert out.n_tokens == expected, out.n_tokens


def test_adapter_shifts_labels_exactly_once(tiny_model_constants):
    """Position t predicts token t+1, and the shift lives on THIS side of the
    boundary (matching sft_loss). A second shift inside the kernel would be
    invisible: it produces a slightly worse but entirely plausible loss. The
    token count is the observable that pins it."""
    model, cfg = tiny_model(tiny_model_constants)
    batch = paired_batch(cfg, pairs=2, seq=10, prompt_len=4, seed=2)
    out = make_dpo_loss_adapter()(model, batch)
    per_side = 10 - 1 - 4 + 1
    assert out.n_tokens == 2 * 2 * per_side


def test_adapter_gradients_reach_the_policy_but_not_the_reference(tiny_model_constants):
    """End-to-end through the real tiny model: the policy trunk and the LM head
    receive gradient, and a requires_grad reference tensor does not. This is
    the section-12 contract exercised through the adapter rather than the
    kernel, because the adapter is where a stray graph edge would be
    introduced."""
    model, cfg = tiny_model(tiny_model_constants)
    batch = paired_batch(cfg, seed=3)
    batch["ref_logp_chosen"] = batch["ref_logp_chosen"].clone().requires_grad_(True)

    out = make_dpo_loss_adapter()(model, batch)
    out.loss.backward()

    assert batch["ref_logp_chosen"].grad is None
    assert model.lm_head.weight.grad is not None
    assert torch.isfinite(model.lm_head.weight.grad).all()
    trunk = model.model.layers[0].attn.q_proj.weight
    assert trunk.grad is not None and trunk.grad.abs().sum() > 0, (
        "no gradient reached the trunk: the loss is not training the model"
    )


def test_missing_reference_logprobs_raise(tiny_model_constants):
    """Guard both directions. A batch without reference log-probs is refused;
    substituting zeros would turn DPO into likelihood maximisation on the
    chosen side, which trains and converges and is not DPO. A complete batch
    is accepted silently."""
    model, cfg = tiny_model(tiny_model_constants)
    adapter = make_dpo_loss_adapter()
    full = paired_batch(cfg, seed=4)

    for key in ("ref_logp_chosen", "ref_logp_rejected", "labels_chosen", "input_ids_rejected"):
        broken = {k: v for k, v in full.items() if k != key}
        with pytest.raises(TypeError, match=key):
            adapter(model, broken)
    assert torch.isfinite(adapter(model, full).loss)


def test_adapter_rejects_unknown_variant():
    """Guard both directions: a typo'd variant raises when the adapter is
    BUILT, not at the first step, and every real variant builds."""
    from align.kernels.fused_dpo import DPO_VARIANTS

    with pytest.raises(ValueError, match="variant"):
        make_dpo_loss_adapter(DpoLossConfig(variant="sigmiod"))
    for variant in DPO_VARIANTS:
        assert make_dpo_loss_adapter(DpoLossConfig(variant=variant)) is not None


def test_token_chunk_size_default_is_not_none():
    """INCIDENT this default prevents: pairs are the chunking unit, and the
    pre-registered config is micro_batch 1 -- ONE pair per step. With
    `token_chunk_size=None` the fused DPO loss would materialise the whole
    [1024, 32768] logit tensor while reporting a healthy loss and passing
    every parity test. The trainer-facing default must therefore set it."""
    cfg = DpoLossConfig()
    assert cfg.token_chunk_size is not None and cfg.token_chunk_size > 0
    assert cfg.beta == 0.1 and cfg.variant == "sigmoid"
    assert cfg.nll_coeff == 0.0, (
        "the NLL auxiliary is a tripwire RESPONSE; it must be off by default"
    )


def test_adapter_config_reaches_the_kernel(tiny_model_constants):
    """The config is not decorative: changing beta must change the loss, and
    the dpop variant must differ from sigmoid on a batch where its hinge is
    open. An adapter that dropped its config on the floor would run every arm
    under the default objective."""
    model, cfg = tiny_model(tiny_model_constants)
    batch = paired_batch(cfg, seed=5)
    lo = make_dpo_loss_adapter(DpoLossConfig(beta=0.1))(model, batch).loss
    hi = make_dpo_loss_adapter(DpoLossConfig(beta=0.9))(model, batch).loss
    assert abs(float(lo.detach()) - float(hi.detach())) > 1e-6

    # reference far above the policy -> the dpop hinge is open
    open_hinge = dict(batch, ref_logp_chosen=torch.zeros_like(batch["ref_logp_chosen"]))
    sig = make_dpo_loss_adapter(DpoLossConfig(variant="sigmoid"))(model, open_hinge).loss
    dpop = make_dpo_loss_adapter(DpoLossConfig(variant="dpop"))(model, open_hinge).loss
    assert float(dpop.detach()) > float(sig.detach()) + 1e-6


def test_fused_dpo_is_available_and_required(tiny_model_constants):
    """Both directions on the require_fused guard: the kernel IS importable
    here, so building with require_fused=True must succeed. The refusal branch
    is what the 7B stage scripts rely on -- a run must not quietly fall back to
    a reference path that no A10G survives."""
    assert fused_dpo_available() is True
    assert make_dpo_loss_adapter(require_fused=True).uses_fused is True


def test_dpo_loss_module_builds_no_reference_model():
    """Structural: nothing in this section constructs or loads a second model.
    The reference log-probs are data. An import that pulled a model in here
    would silently double resident weights at DPO time."""
    import pathlib

    src = pathlib.Path(__file__).resolve().parents[1] / "align" / "train" / "dpo_loss.py"
    text = src.read_text()
    for forbidden in ("from_pretrained", "load_state_dict", "AutoModel", "NmoMistralForCausalLM"):
        assert forbidden not in text, f"dpo_loss.py must not reference {forbidden!r}"