File size: 17,348 Bytes
6e802a4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# 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()