mamba-cot-tts / code /tests /test_plan_injection.py
maxpicy's picture
Add files using upload-large-folder tool
6e802a4 verified
Raw History Blame Contribute Delete
17.3 kB
# test_plan_injection.py: Duration-scheduled plan injection.
import pytest
import torch
import torch.nn as nn
from model import (
PLAN_BIN_ABSENT,
PLAN_BIN_CHANNELS,
DelayEmbedding,
PlanInjector,
build_plan_schedule,
)
CUDA = torch.cuda.is_available()
D_MODEL = 8
VOCAB = 32
def _delay_embedding(d_model=D_MODEL, vocab=VOCAB, num_levels=2, codebook=4):
torch.manual_seed(0)
base = nn.Embedding(vocab, d_model)
nn.init.normal_(base.weight, std=1.0)
return DelayEmbedding(base, num_levels=num_levels, codebook_size=codebook)
def _injector(scaffold_dropout=0.0, d_model=D_MODEL, seed=0):
torch.manual_seed(seed)
return PlanInjector(d_model=d_model, n_pitch_bins=16, n_duration_bins=16,
n_energy_bins=8, d_plan=6,
scaffold_dropout=scaffold_dropout)
def _train_injector(inj):
with torch.no_grad():
torch.manual_seed(1)
nn.init.normal_(inj.proj.weight, std=0.5)
nn.init.normal_(inj.proj.bias, std=0.5)
return inj
def test_set_plan_adds_per_position_and_speaker_still_broadcasts():
emb = _delay_embedding()
ids = torch.arange(5).unsqueeze(0)
base = emb(ids)
plan = torch.zeros(1, 5, D_MODEL)
plan[0, 2] = 1.0
emb.set_plan(plan)
with_plan = emb(ids)
assert torch.equal(with_plan[0, 2], base[0, 2] + 1.0)
for p in (0, 1, 3, 4):
assert torch.equal(with_plan[0, p], base[0, p]), \
"set_plan must be per-position, not broadcast"
spk = torch.full((1, D_MODEL), 2.0)
emb.set_speaker(spk)
with_spk = emb(ids)
for p in range(5):
assert torch.equal(with_spk[0, p], base[0, p] + 2.0), \
"set_speaker must still broadcast to every position"
def test_plan_and_speaker_compose():
emb = _delay_embedding()
ids = torch.arange(4).unsqueeze(0)
base = emb(ids)
plan = torch.zeros(1, 4, D_MODEL)
plan[0, 1] = 1.0
plan[0, 3] = 4.0
spk = torch.full((1, D_MODEL), 2.0)
emb.set_plan(plan)
emb.set_speaker(spk)
both = emb(ids)
expected = base + plan + 2.0
assert torch.equal(both, expected), \
"staging a speaker AND a plan must add both channels"
def test_staged_plan_consumed_exactly_once():
emb = _delay_embedding()
ids = torch.arange(3).unsqueeze(0)
base = emb(ids)
plan = torch.full((1, 3, D_MODEL), 7.0)
emb.set_plan(plan)
first = emb(ids)
assert torch.equal(first, base + 7.0)
second = emb(ids)
assert torch.equal(second, base), \
"a stale plan must NOT be re-applied on the next forward"
assert emb._plan is None
def test_codes_and_plan_are_independent_slots():
emb = _delay_embedding()
ids = torch.arange(3).unsqueeze(0)
base = emb(ids)
with torch.no_grad():
emb.level_embeds[0].weight.fill_(0.5)
codes = torch.full((1, 3, 2), -1, dtype=torch.long)
codes[0, 1] = torch.tensor([0, 0])
plan = torch.zeros(1, 3, D_MODEL)
plan[0, 2] = 3.0
emb.set_codes(codes)
emb.set_plan(plan)
out = emb(ids)
assert torch.equal(out[0, 0], base[0, 0])
assert torch.equal(out[0, 1], base[0, 1] + 0.5)
assert torch.equal(out[0, 2], base[0, 2] + 3.0)
def _sched(durs, audio_start, n_frames, seq_len=None):
return build_plan_schedule(durs, audio_start, n_frames,
seq_len=seq_len).tolist()
def test_schedule_basic_two_words():
assert _sched([[2, 3]], 0, 5) == [[0, 0, 1, 1, 1]]
def test_schedule_audio_start_offset():
got = _sched([[2, 3]], 3, 5, seq_len=10)
assert got == [[-1, -1, -1, 0, 0, 1, 1, 1, -1, -1]]
def test_schedule_single_word_plan():
assert _sched([[4]], 0, 4) == [[0, 0, 0, 0]]
def test_schedule_last_word_absorbs_the_remainder():
assert _sched([[2, 2]], 0, 6) == [[0, 0, 1, 1, 1, 1]]
def test_schedule_plan_longer_than_audio_truncates_cleanly():
assert _sched([[5, 3, 2]], 0, 3) == [[0, 0, 0]]
def test_schedule_zero_duration_word_is_skipped():
assert _sched([[3, 0, 2]], 0, 7) == [[0, 0, 0, 2, 2, 2, 2]]
def test_schedule_float_durations_round_half_up():
assert _sched([[1.4, 2.6]], 0, 4) == [[0, 1, 1, 1]]
def test_schedule_batch_with_padding_and_ragged_rows():
durs = [[2, 1, PLAN_BIN_ABSENT],
[1, 1, 1]]
got = _sched(durs, [1, 0], [3, 3], seq_len=5)
assert got == [[-1, 0, 0, 1, -1],
[0, 1, 2, -1, -1]]
def test_schedule_accepts_tensor_input():
durs = torch.tensor([[2, 3], [4, -1]])
got = build_plan_schedule(durs, 0, 5, seq_len=5).tolist()
assert got == [[0, 0, 1, 1, 1],
[0, 0, 0, 0, 0]]
def test_schedule_accepts_numpy_and_scalar_types():
np = pytest.importorskip("numpy")
durs = np.array([[2, 3]], dtype=np.int64)
got = build_plan_schedule(durs, np.int64(1), np.int64(5), seq_len=6).tolist()
assert got == [[-1, 0, 0, 1, 1, 1]]
got_f = build_plan_schedule(np.array([[1.4, 2.6]]), 0, 4, seq_len=4).tolist()
assert got_f == [[0, 1, 1, 1]]
def test_schedule_empty_plan_row_is_all_minus_one():
got = _sched([[], [2]], 0, 2, seq_len=2)
assert got == [[-1, -1], [0, 0]]
def test_schedule_no_frames_is_all_minus_one():
assert _sched([[3]], 2, 0, seq_len=4) == [[-1, -1, -1, -1]]
def test_schedule_default_seq_len_is_the_audio_end():
assert _sched([[2]], 2, 2) == [[-1, -1, 0, 0]]
def test_schedule_rejects_audio_block_past_seq_len():
with pytest.raises(ValueError, match="past seq_len"):
build_plan_schedule([[4]], 3, 4, seq_len=5)
def test_schedule_rejects_interior_padding():
with pytest.raises(ValueError, match="interior position"):
build_plan_schedule([[2, -1, 3]], 0, 5)
def test_schedule_rejects_stray_negative_duration():
with pytest.raises(ValueError, match="pad sentinel"):
build_plan_schedule([[2, -3]], 0, 5)
def test_schedule_rejects_negative_audio_start():
with pytest.raises(ValueError, match="audio_start"):
build_plan_schedule([[2]], -1, 2, seq_len=4)
def test_schedule_rejects_flat_duration_sequence():
with pytest.raises(ValueError, match="PER-ROW"):
build_plan_schedule([2, 3], 0, 5)
def test_schedule_batch_length_mismatch_is_loud():
with pytest.raises(ValueError, match="audio_start"):
build_plan_schedule([[2], [2]], [0, 0, 0], 2)
def test_schedule_total_frames_match_durations():
durs = [3, 1, 4, 2, 5]
sched = build_plan_schedule([durs], 7, sum(durs), seq_len=7 + sum(durs))[0]
for w, d in enumerate(durs):
assert int((sched == w).sum()) == d, f"word {w} got the wrong span"
assert int((sched == -1).sum()) == 7
def _bins(rows):
return torch.tensor(rows, dtype=torch.long)
def test_zero_init_injection_is_an_exact_noop():
inj = _injector()
plan = _bins([[[7, 4, 3], [9, 2, 4]]])
sched = build_plan_schedule([[2, 2]], 1, 4, seq_len=6)
out = inj(plan, sched)
assert torch.equal(out, torch.zeros_like(out)), \
"fresh PlanInjector must emit exactly zero (zero-init proj)"
emb = _delay_embedding()
ids = torch.arange(6).unsqueeze(0)
base = emb(ids)
emb.set_plan(out)
assert torch.equal(emb(ids), base), \
"enabling injection must not perturb the forward before training"
def test_non_audio_positions_stay_zero_after_training():
inj = _train_injector(_injector())
plan = _bins([[[7, 4, 3], [9, 2, 4]]])
sched = build_plan_schedule([[2, 2]], 2, 4, seq_len=8)
out = inj(plan, sched)
assert torch.equal(out[0, :2], torch.zeros(2, D_MODEL))
assert torch.equal(out[0, 6:], torch.zeros(2, D_MODEL))
assert out[0, 2:6].abs().sum() > 0
def test_absent_and_unvoiced_pitch_share_the_absent_row():
inj = _train_injector(_injector())
sched = build_plan_schedule([[2]], 0, 2, seq_len=2)
a = inj(_bins([[[PLAN_BIN_ABSENT, 4, 3]]]), sched)
b = inj(_bins([[[16, 4, 3]]]), sched)
assert torch.equal(a, b)
c = inj(_bins([[[0, 4, 3]]]), sched)
assert not torch.equal(a, c), "bin 0 must differ from 'no pitch'"
def test_out_of_range_bin_is_loud():
inj = _injector()
sched = build_plan_schedule([[1]], 0, 1, seq_len=1)
with pytest.raises(ValueError, match="pitch bin 17"):
inj(_bins([[[17, 0, 0]]]), sched)
with pytest.raises(ValueError, match="energy bin 9"):
inj(_bins([[[0, 0, 9]]]), sched)
with pytest.raises(ValueError, match="duration bin -2"):
inj(_bins([[[0, -2, 0]]]), sched)
def test_schedule_referencing_a_missing_word_is_loud():
inj = _injector()
plan = _bins([[[1, 1, 1]]])
sched = torch.tensor([[0, 1]])
with pytest.raises(ValueError, match="references word 1"):
inj(plan, sched)
def test_empty_plan_tensor_is_loud():
inj = _injector()
with pytest.raises(ValueError, match="zero words"):
inj(torch.zeros(1, 0, 3, dtype=torch.long), torch.full((1, 2), -1))
def test_bin_channel_order_is_pitch_duration_energy():
assert PLAN_BIN_CHANNELS == ("pitch", "duration", "energy")
inj = _train_injector(_injector())
sched = build_plan_schedule([[1]], 0, 1, seq_len=1)
straight = inj(_bins([[[1, 2, 3]]]), sched)
swapped = inj(_bins([[[2, 1, 3]]]), sched)
assert not torch.equal(straight, swapped)
def test_gradient_reaches_the_injector():
inj = _injector()
plan = _bins([[[7, 4, 3], [9, 2, 4]]])
sched = build_plan_schedule([[2, 2]], 0, 4, seq_len=4)
inj(plan, sched).sum().backward()
assert inj.proj.weight.grad is not None
assert inj.proj.weight.grad.abs().sum() > 0
assert inj.proj.bias.grad.abs().sum() > 0
opt = torch.optim.SGD(inj.parameters(), lr=0.1)
opt.step()
opt.zero_grad()
inj(plan, sched).sum().backward()
grads = [e.weight.grad.abs().sum().item() for e in inj.embeds]
assert all(g > 0 for g in grads), f"dead bin embedding(s): {grads}"
for e in inj.embeds:
assert torch.equal(e.weight.grad[0], torch.zeros(inj.d_plan))
def test_scaffold_dropout_drops_whole_utterances_in_train_mode():
inj = _train_injector(_injector(scaffold_dropout=1.0))
plan = _bins([[[7, 4, 3], [9, 2, 4]]])
sched = build_plan_schedule([[2, 2]], 0, 4, seq_len=4)
inj.train()
out = inj(plan, sched)
assert torch.equal(out, torch.zeros_like(out)), \
"p=1.0 in train mode must drop the injection entirely"
inj.eval()
out_eval = inj(plan, sched)
assert out_eval.abs().sum() > 0, \
"scaffold dropout must be a no-op in eval mode (it is a training " \
"scaffold, not an inference condition)"
def test_scaffold_dropout_is_per_utterance_not_per_position():
torch.manual_seed(3)
inj = _train_injector(_injector(scaffold_dropout=0.5))
inj.train()
plan = _bins([[[7, 4, 3]]] * 16)
sched = build_plan_schedule([[4]] * 16, 0, 4, seq_len=4)
out = inj(plan, sched)
per_row_alive = (out.abs().sum(dim=(1, 2)) > 0)
for b in range(16):
row = out[b]
alive = (row.abs().sum(dim=-1) > 0)
assert bool(alive.all()) == bool(per_row_alive[b]), \
"dropout must apply to the whole utterance, not per position"
assert 0 < int(per_row_alive.sum()) < 16, \
"p=0.5 over 16 rows should drop some and keep some (seeded)"
def test_scaffold_dropout_keeps_params_in_the_graph():
inj = _train_injector(_injector(scaffold_dropout=1.0))
inj.train()
plan = _bins([[[7, 4, 3]]])
sched = build_plan_schedule([[2]], 0, 2, seq_len=2)
out = inj(plan, sched)
assert out.requires_grad
out.sum().backward()
assert inj.proj.weight.grad is not None
def test_scaffold_dropout_rejects_non_probabilities():
with pytest.raises(ValueError, match="scaffold_dropout"):
_injector(scaffold_dropout=1.5)
with pytest.raises(ValueError, match="scaffold_dropout"):
PlanInjector(d_model=4, n_pitch_bins=2, n_duration_bins=2,
n_energy_bins=2, scaffold_dropout=-0.1)
def test_different_plan_changes_the_injected_stream():
inj = _train_injector(_injector())
sched = build_plan_schedule([[2, 2]], 0, 4, seq_len=4)
a = inj(_bins([[[7, 4, 3], [9, 2, 4]]]), sched)
b = inj(_bins([[[11, 4, 3], [9, 2, 4]]]), sched)
assert not torch.equal(a, b), \
"if flipping a plan bin leaves the input stream unchanged, the plan " \
"is decorative by construction"
assert not torch.equal(a[0, :2], b[0, :2])
assert torch.equal(a[0, 2:], b[0, 2:])
def test_different_durations_reschedule_the_stream():
inj = _train_injector(_injector())
plan = _bins([[[7, 4, 3], [9, 2, 4]]])
a = inj(plan, build_plan_schedule([[2, 2]], 0, 4, seq_len=4))
b = inj(plan, build_plan_schedule([[3, 1]], 0, 4, seq_len=4))
assert torch.equal(a[0, :2], b[0, :2])
assert not torch.equal(a[0, 2], b[0, 2])
assert torch.equal(a[0, 3], b[0, 3])
def test_injected_stream_is_constant_within_a_word():
inj = _train_injector(_injector())
plan = _bins([[[7, 4, 3], [9, 2, 4]]])
sched = build_plan_schedule([[3, 2]], 0, 5, seq_len=5)
out = inj(plan, sched)[0]
vec = inj.word_vectors(plan)
gathered = vec[0][sched[0].clamp(min=0)]
assert torch.equal(gathered[0], gathered[1])
assert torch.equal(gathered[3], gathered[4])
assert not torch.equal(gathered[2], gathered[3])
for a, b in ((0, 1), (1, 2), (3, 4)):
assert torch.allclose(out[a], out[b], atol=1e-6)
assert (out[2] - out[3]).abs().max() > 1e-3
@pytest.fixture(scope="module")
def depth_model():
from model import MambaCoTModel
if not CUDA:
pytest.skip("needs CUDA")
m = MambaCoTModel(model_name="state-spaces/mamba2-130m", device="cuda",
dtype=torch.bfloat16, mtp_num_heads=0)
m.enable_depth_module(num_levels=8, codebook_size=2048)
return m
def _depth_batch(model, seq_len=24, audio_start=8, n_frames=8):
V = len(model.tokenizer)
ids = torch.randint(0, min(V, 1000), (1, seq_len), device="cuda")
labels = ids.clone()
codes = torch.full((1, seq_len, 8), -1, dtype=torch.long, device="cuda")
codes[0, audio_start:audio_start + n_frames] = torch.randint(
0, 2048, (n_frames, 8), device="cuda")
plan = torch.tensor([[[7, 4, 3], [9, 2, 4]]], device="cuda")
sched = build_plan_schedule([[4, 4]], audio_start, n_frames,
seq_len=seq_len, device="cuda")
return ids, labels, codes, plan, sched
@pytest.mark.skipif(not CUDA, reason="needs CUDA")
def test_model_zero_init_plan_injection_is_exact(depth_model):
m = depth_model
ids, labels, codes, plan, sched = _depth_batch(m)
with torch.no_grad():
base = m.forward_depth(ids, codes, labels=labels)
m.enable_plan_injection(scaffold_dropout=0.0)
with torch.no_grad():
inj = m.forward_depth(ids, codes, labels=labels,
plan_bins=plan, plan_schedule=sched)
assert torch.equal(base["logits"], inj["logits"]), \
"fresh plan injection must be an exact no-op (zero-init projection)"
assert torch.equal(base["loss"], inj["loss"])
@pytest.mark.skipif(not CUDA, reason="needs CUDA")
def test_model_plan_state_dict_only_grows_when_enabled(depth_model):
from model import MambaCoTModel
keys = [k for k in depth_model.state_dict() if k.startswith("plan_injector")]
assert keys, "enabled model must expose plan_injector.* parameters"
assert not any(k.endswith(("in_proj.weight", "out_proj.weight")) for k in keys)
fresh = MambaCoTModel(model_name="state-spaces/mamba2-130m", device="cuda",
dtype=torch.bfloat16, mtp_num_heads=0)
fresh.enable_depth_module(num_levels=8, codebook_size=2048)
assert not [k for k in fresh.state_dict() if k.startswith("plan_injector")], \
"a model without enable_plan_injection() must add no parameters"
@pytest.mark.skipif(not CUDA, reason="needs CUDA")
def test_model_grad_flows_to_the_injector(depth_model):
m = depth_model
if not m.use_plan_injection:
m.enable_plan_injection(scaffold_dropout=0.0)
ids, labels, codes, plan, sched = _depth_batch(m)
m.zero_grad(set_to_none=True)
out = m.forward_depth(ids, codes, labels=labels, plan_bins=plan,
plan_schedule=sched)
out["loss"].backward()
assert m.plan_injector.proj.weight.grad is not None
assert m.plan_injector.proj.weight.grad.abs().sum() > 0
m.zero_grad(set_to_none=True)
@pytest.mark.skipif(not CUDA, reason="needs CUDA")
def test_model_half_a_plan_is_loud(depth_model):
m = depth_model
if not m.use_plan_injection:
m.enable_plan_injection(scaffold_dropout=0.0)
ids, labels, codes, plan, sched = _depth_batch(m)
with pytest.raises(ValueError, match="must be supplied together"):
m.forward_depth(ids, codes, labels=labels, plan_bins=plan)
with pytest.raises(ValueError, match="must be supplied together"):
m.forward_depth(ids, codes, labels=labels, plan_schedule=sched)
@pytest.mark.skipif(not CUDA, reason="needs CUDA")
def test_plan_injection_requires_the_delay_embedding_channel():
from model import MambaCoTModel
m = MambaCoTModel(model_name="state-spaces/mamba2-130m", device="cuda",
dtype=torch.bfloat16, mtp_num_heads=0)
with pytest.raises(RuntimeError, match="DelayEmbedding"):
m.enable_plan_injection()