File size: 6,820 Bytes
90884df
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
from __future__ import annotations

import copy

import pytest
import torch
from torch import nn

from music3lab.codec.external_finetune import (
    ExternalSource,
    FineTuneMetrics,
    WarmupCosineSchedule,
    deterministic_guarded_crop,
    evaluate_gates,
    load_source_splits,
    mixed_batch_loss,
    select_checkpoint,
    serialize_source_splits,
    split_external_sources,
)


USER22 = tuple(f"user22-{index:02d}" for index in range(22))


def _sources() -> tuple[ExternalSource, ...]:
    """Two thousand admissible sources plus user music that must stay OOD."""
    return tuple(
        ExternalSource(source_id=f"external-{index:04d}", samples=44100 * 12)
        for index in range(2000)
    ) + tuple(
        ExternalSource(source_id=source_id, samples=44100 * 12, user_provided=True)
        for source_id in USER22
    )


def test_source_exclusive_1600_200_200_split_and_guarded_crops_are_deterministic() -> None:
    first = split_external_sources(_sources(), seed=73)
    second = split_external_sources(_sources(), seed=73)

    assert {name: len(rows) for name, rows in first.items()} == {
        "train": 1600,
        "validation": 200,
        "heldout": 200,
    }
    assert first == second
    owners = {
        row.source_id: name for name, rows in first.items() for row in rows
    }
    assert len(owners) == 2000
    assert not set(USER22) & set(owners)

    crop = deterministic_guarded_crop(
        first["train"][0], split="train", epoch=3, item_index=7, seed=73
    )
    assert crop == deterministic_guarded_crop(
        first["train"][0], split="train", epoch=3, item_index=7, seed=73
    )
    assert crop.samples == 44032
    assert 44100 * 5 <= crop.start_sample
    assert crop.start_sample + crop.samples <= 44100 * 7
    assert crop.source_id in owners and owners[crop.source_id] == "train"


def test_manifest_loader_rejects_user_audio_from_train_or_checkpoint_selection(tmp_path) -> None:
    manifest = split_external_sources(_sources(), seed=9)
    path = tmp_path / "sources.json"
    path.write_text(serialize_source_splits(manifest), encoding="utf-8")

    loaded = load_source_splits(path)
    assert loaded == manifest
    assert not set(USER22) & {
        row.source_id for rows in loaded.values() for row in rows
    }
    with pytest.raises(ValueError, match="user-provided"):
        split_external_sources(
            _sources(), seed=9, train_source_ids=("external-0000", USER22[0])
        )


def test_mixed_loss_uses_six_external_two_music3_and_exact_weights() -> None:
    teacher_nmse = torch.tensor(2.0)
    teacher_ruler = torch.tensor(3.0)
    external_ruler = torch.tensor(5.0)
    prior = torch.tensor(7.0)
    result = mixed_batch_loss(
        teacher_nmse=teacher_nmse,
        teacher_ruler=teacher_ruler,
        external_ruler=external_ruler,
        external_prior=prior,
        external_count=6,
        music3_count=2,
    )

    assert result.external_count == 6
    assert result.music3_count == 2
    assert float(result.total) == pytest.approx(
        0.70 * 5.0 + 0.30 * (2.0 + 0.05 * 3.0) + 0.005 * 7.0
    )
    assert result.teacher_weight == pytest.approx(0.30)
    assert result.external_weight == pytest.approx(0.70)


def test_only_encoder_receives_gradients_and_frozen_decoder_is_unchanged() -> None:
    encoder = nn.Linear(2, 2, bias=False)
    decoder = nn.Linear(2, 2, bias=False)
    for parameter in decoder.parameters():
        parameter.requires_grad_(False)
    decoder_before = copy.deepcopy(decoder.state_dict())

    teacher_audio = torch.tensor([[1.0, -1.0], [0.5, 0.25]])
    external_audio = torch.tensor([[0.25, -0.5]]).repeat(6, 1)
    predicted = encoder(torch.cat((teacher_audio, external_audio), dim=0))
    rendered = decoder(predicted)
    result = mixed_batch_loss(
        teacher_nmse=(predicted[:2] - teacher_audio).square().mean(),
        teacher_ruler=(rendered[:2] - teacher_audio).square().mean(),
        external_ruler=(rendered[2:] - external_audio).square().mean(),
        external_prior=predicted[2:].square().mean(),
        external_count=6,
        music3_count=2,
    )
    result.total.backward()

    assert all(parameter.grad is not None for parameter in encoder.parameters())
    assert all(parameter.grad is None for parameter in decoder.parameters())
    assert decoder.state_dict().keys() == decoder_before.keys()
    assert all(torch.equal(value, decoder_before[key]) for key, value in decoder.state_dict().items())


def test_warmup_cosine_endpoints_are_frozen() -> None:
    schedule = WarmupCosineSchedule(
        warmup_steps=10,
        total_steps=100,
        maximum_learning_rate=1e-3,
        minimum_learning_rate=1e-5,
    )
    assert schedule(0) == pytest.approx(0.0)
    assert schedule(10) == pytest.approx(1e-3)
    assert schedule(100) == pytest.approx(1e-5)
    assert schedule(50) < schedule(10)


def test_synthetic_training_improves_external_ruler_without_teacher_regression() -> None:
    scale = nn.Parameter(torch.zeros(()))
    optimizer = torch.optim.SGD([scale], lr=0.15)
    baseline = FineTuneMetrics(teacher_ruler=1.0, external_ruler=1.1025)
    for _ in range(50):
        optimizer.zero_grad()
        loss = mixed_batch_loss(
            teacher_nmse=(scale - 1.0).square(),
            teacher_ruler=(scale - 1.0).square(),
            external_ruler=(scale - 1.05).square(),
            external_prior=scale.square(),
            external_count=6,
            music3_count=2,
        )
        loss.total.backward()
        optimizer.step()
    candidate = FineTuneMetrics(
        teacher_ruler=float((scale.detach() - 1.0).square()),
        external_ruler=float((scale.detach() - 1.05).square()),
    )
    gate = evaluate_gates(baseline=baseline, candidate=candidate)

    assert gate.external_ruler_improved is True
    assert gate.teacher_regression_fraction <= 0.05
    assert gate.teacher_regression_within_limit is True
    assert gate.passes is True


def test_heldout_gates_and_user22_ood_report_cannot_select_a_checkpoint() -> None:
    baseline = FineTuneMetrics(teacher_ruler=1.0, external_ruler=4.0)
    checkpoints = {
        "step-010": FineTuneMetrics(teacher_ruler=1.02, external_ruler=2.0),
        "step-020": FineTuneMetrics(teacher_ruler=1.01, external_ruler=2.1),
    }
    selected = select_checkpoint(
        baseline=baseline,
        validation=checkpoints,
        heldout={"step-010": FineTuneMetrics(teacher_ruler=4.0, external_ruler=0.1)},
        user22_ood={"step-010": FineTuneMetrics(teacher_ruler=0.0, external_ruler=0.0)},
    )

    assert selected.name == "step-010"
    assert selected.selection_split == "validation"
    assert selected.heldout_gate is not None
    assert selected.user22_ood_report is not None
    assert selected.user22_ood_report.influenced_selection is False