File size: 25,960 Bytes
0ba9d09
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Lecture 4: the worked numbers of Chapter 4, asserted against the files that print them.

Every assertion here is a number the chapter computes by hand. The tests import the lecture files
directly, so they check the same functions the scripts run, and none of them trains anything.
"""

from __future__ import annotations

import importlib.util
import math
import sys
from pathlib import Path

import pytest
import torch

LECTURE = Path(__file__).resolve().parents[1] / "lecture_4"


def load(name: str):
    """Import one lecture file by path, since lecture_4 is a folder of scripts and not a package."""
    spec = importlib.util.spec_from_file_location(f"lecture_4_{name}", LECTURE / f"{name}.py")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


ctmc = load("ctmc")
mdlm = load("mdlm")
uniform = load("uniform")
d3pm = load("d3pm")
sedd = load("sedd")
block = load("block")
guidance = load("guidance")
planner = load("planner")
search = load("search")
simplex = load("simplex")


# %% 01. ctmc: rates, the master equation, the exponential and the reversal


def test_two_rules_and_the_dna_row():
    """A jumps to C at rate 2 and to G and T at rate 1, so its diagonal entry is -4."""
    assert ctmc.obeys_the_two_rules(ctmc.DNA_RATES)
    assert float(ctmc.DNA_RATES[0, 0]) == pytest.approx(-4.0)
    assert float(ctmc.exit_rates(ctmc.DNA_RATES)[0]) == pytest.approx(4.0)


def test_short_interval_row_and_the_step_bound():
    """P_{t,t+0.1}(A, .) = (0.6, 0.2, 0.1, 0.1), and the step must satisfy h <= 0.25."""
    row = ctmc.short_interval_row(ctmc.DNA_RATES, 0, 0.1)
    assert [round(float(v), 4) for v in row] == [0.6, 0.2, 0.1, 0.1]
    assert math.exp(-0.4) == pytest.approx(0.6703, abs=5e-5)
    assert ctmc.largest_valid_step(ctmc.DNA_RATES) == pytest.approx(0.25)
    assert float(ctmc.short_interval_row(ctmc.DNA_RATES, 0, 0.3)[0]) == pytest.approx(-0.2)


def test_waiting_time_from_the_chapters_uniform():
    """We invert S(s) = exp(-4s) at u = 0.42 and get a wait of 0.2169."""
    assert -math.log(0.42) / 4.0 == pytest.approx(0.2169, abs=5e-5)
    assert math.log(2.0) / 4.0 == pytest.approx(0.1733, abs=5e-5)


def test_master_equation_on_four_dna_probabilities():
    """p_dot = (0.6, 0.2, -0.2, -0.6) and one Euler step gives (0.16, 0.22, 0.28, 0.34)."""
    derivative = ctmc.master_equation(ctmc.START, ctmc.UNIT_RATES)
    assert [round(float(v), 4) for v in derivative] == [0.6, 0.2, -0.2, -0.6]
    arriving, leaving = ctmc.arrivals_and_departures(ctmc.START, ctmc.UNIT_RATES)
    assert float(arriving[0]) == pytest.approx(0.9)
    assert float(leaving[0]) == pytest.approx(0.3)
    stepped = ctmc.START + 0.1 * derivative
    assert [round(float(v), 4) for v in stepped] == [0.16, 0.22, 0.28, 0.34]
    assert float(derivative.sum()) == pytest.approx(0.0, abs=1e-12)


def test_exact_evolution_matches_the_matrix_exponential():
    """1/4 + (p_0 - 1/4) exp(-4t) agrees with exp(tR) at t = 0.1 and at t = 1."""
    for t, expected in ((0.1, [0.1495, 0.2165, 0.2835, 0.3505]),
                        (1.0, [0.2473, 0.2491, 0.2509, 0.2527])):
        exact = ctmc.exact_evolution(ctmc.START, t)
        assert [round(float(v), 4) for v in exact] == expected
        through_matrix = ctmc.START @ ctmc.transition_matrix(ctmc.UNIT_RATES, t)
        assert float((exact - through_matrix).abs().max()) == pytest.approx(0.0, abs=1e-12)


def test_the_matrix_exponential_worked_for_dna():
    """At tau = log 2 the uniform kernel is 0.625 on the diagonal and 0.125 elsewhere."""
    pi = torch.full((4,), 0.25, dtype=torch.float64)
    tau = math.log(2.0)
    kernel = ctmc.transition_matrix(ctmc.r_pi(pi), tau)
    assert float(kernel[0, 0]) == pytest.approx(0.625, abs=1e-12)
    assert float(kernel[0, 1]) == pytest.approx(0.125, abs=1e-12)
    assert float(kernel.sum(dim=1).min()) == pytest.approx(1.0)
    closed = ctmc.pi_kernel(pi, math.exp(-tau))
    assert float((kernel - closed).abs().max()) == pytest.approx(0.0, abs=1e-12)
    euler = {n: float(ctmc.euler_product(ctmc.r_pi(pi), tau, n)[0, 0]) for n in (1, 2, 10)}
    assert [round(euler[n], 3) for n in (1, 2, 10)] == [0.480, 0.570, 0.616]


def test_masking_kernel_is_absorbing():
    """At alpha = 1/2 the masking kernel keeps each base with 0.5 and sends 0.5 to the mask."""
    mask_pi = torch.zeros(5, dtype=torch.float64)
    mask_pi[4] = 1.0
    kernel = ctmc.pi_kernel(mask_pi, 0.5)
    assert float(kernel[0, 0]) == pytest.approx(0.5)
    assert float(kernel[0, 4]) == pytest.approx(0.5)
    assert float(kernel[4, 4]) == pytest.approx(1.0)


def test_composition_eigenvalues_and_detailed_balance():
    """Intervals compose, R_pi has eigenvalues 0 and -1, and the cycle breaks detailed balance."""
    whole = ctmc.transition_matrix(ctmc.UNIT_RATES, 0.7)
    halves = ctmc.transition_matrix(ctmc.UNIT_RATES, 0.3) @ ctmc.transition_matrix(
        ctmc.UNIT_RATES, 0.4)
    assert float((whole - halves).abs().max()) == pytest.approx(0.0, abs=1e-12)
    pi = torch.full((4,), 0.25, dtype=torch.float64)
    eigenvalues = sorted(round(float(v.real), 6) for v in torch.linalg.eigvals(ctmc.r_pi(pi)))
    assert eigenvalues == [-1.0, -1.0, -1.0, 0.0]
    assert float((pi @ ctmc.r_pi(pi)).abs().max()) == pytest.approx(0.0, abs=1e-12)
    assert ctmc.detailed_balance_gap(ctmc.r_pi(pi), pi) == pytest.approx(0.0, abs=1e-12)
    cycle_pi = torch.full((3,), 1.0 / 3.0, dtype=torch.float64)
    assert float((cycle_pi @ ctmc.CYCLE).abs().max()) == pytest.approx(0.0, abs=1e-12)
    assert ctmc.detailed_balance_gap(ctmc.CYCLE, cycle_pi) == pytest.approx(1.0 / 3.0)


def test_time_reversal_retraces_the_marginals():
    """With p_t = (0.5, 0.25, 0.125, 0.125) the reverse rate out of C toward A is 2."""
    marginal = torch.tensor([0.5, 0.25, 0.125, 0.125], dtype=torch.float64)
    backward = ctmc.reverse_rates(ctmc.UNIT_RATES, marginal)
    assert float(backward[1, 0]) == pytest.approx(2.0)
    assert float(backward[1, 2]) == pytest.approx(0.5)
    assert float(ctmc.exit_rates(backward)[1]) == pytest.approx(3.0)
    net = marginal @ backward + marginal @ ctmc.UNIT_RATES
    assert float(net.abs().max()) == pytest.approx(0.0, abs=1e-12)


# %% 02. mdlm: the corruption, the loss and the sampler


def test_reverse_step_worked():
    """At alpha_s = 0.7 and alpha_t = 0.4 the reveal probability is 0.5."""
    assert mdlm.reveal_probability(0.7, 0.4) == pytest.approx(0.5)
    law = mdlm.reverse_step_distribution(0.7, 0.4, [0.6, 0.4, 0.0, 0.0])
    assert [round(v, 4) for v in law] == [0.3, 0.2, 0.0, 0.0, 0.5]
    assert sum(law) == pytest.approx(1.0)


def test_one_term_of_the_loss_worked():
    """w(0.5) = 2 and the masked cross-entropy of 1.04982 give a weighted term of 2.09964."""
    assert mdlm.reveal_rate(0.5) == pytest.approx(2.0)
    masked = -math.log(0.7) - math.log(0.5)
    assert masked == pytest.approx(1.04982, abs=5e-6)
    assert 2.0 * masked == pytest.approx(2.09964, abs=5e-6)


def test_the_rate_error_collapses_to_the_cross_entropy():
    """At a masked position the rate errors sum to w(t) times the negative log probability."""
    rates = [2.0 * d for d in (0.2, 0.6, 0.1, 0.1)]
    assert [round(v, 4) for v in rates] == [0.4, 1.2, 0.2, 0.2]
    total = mdlm.rate_error(2.0, rates[1]) + sum(
        mdlm.rate_error(0.0, r) for i, r in enumerate(rates) if i != 1)
    assert total == pytest.approx(2.0 * -math.log(0.6), abs=1e-9)
    assert total == pytest.approx(1.0217, abs=5e-5)
    assert 2.0 * -math.log(0.4) == pytest.approx(1.8326, abs=5e-5)


def test_corruption_and_sampling_shapes():
    """The corruption keeps or masks, and the sampler returns clean DNA with no mask left."""
    torch.manual_seed(0)
    x0 = torch.randint(0, 4, (8, 6))
    zt = mdlm.corrupt(x0, torch.full((8, 1), 0.5))
    assert bool(((zt == x0) | (zt == mdlm.MASK)).all())
    kept = mdlm.corrupt(x0, torch.zeros(8, 1)).eq(x0).to(torch.get_default_dtype())
    assert float(kept.mean()) == 1.0
    assert int(mdlm.corrupt(x0, torch.ones(8, 1)).eq(mdlm.MASK).all())
    model = _tiny_denoiser(5, 6)
    drawn = mdlm.sample(model, 4, 6, steps=4)
    assert drawn.shape == (4, 6) and int(drawn.max()) < 4


def _tiny_denoiser(vocab: int, length: int, num_classes: int = 0):
    """A very small SequenceDenoiser, so the sampler tests cost nothing."""
    from cis6270.nets import SequenceDenoiser

    torch.manual_seed(0)
    return SequenceDenoiser(vocab, length, width=16, depth=1, heads=2, num_classes=num_classes)


# %% 03. uniform: the posterior average of 2, and the rate error that keeps its staying term


def test_the_uniform_kernel_and_the_clean_factor():
    """At alpha_t = 1/2 the kernel is 0.625 at the clean base and 0.125 elsewhere."""
    assert float(uniform.kernel(0.5)[0, 0]) == pytest.approx(0.625)
    assert float(uniform.kernel(0.5)[0, 1]) == pytest.approx(0.125)
    assert uniform.clean_factor(0.5) == pytest.approx(5.0)


def test_the_worked_posterior_average_is_two():
    """The chapter's posterior average of the conditional reverse rates on one base comes out 2."""
    prior = torch.tensor([0.75, 0.25, 0.0, 0.0], dtype=torch.float64)
    posterior = uniform.posterior_over_clean(0.5, 1, prior)
    assert [round(float(v), 4) for v in posterior] == [0.375, 0.625, 0.0, 0.0]
    assert float(uniform.conditional_rates(0.5, current=1, clean=0)[0]) == pytest.approx(5.0)
    assert float(uniform.conditional_rates(0.5, current=1, clean=1)[0]) == pytest.approx(0.2)
    assert uniform.marginal_reverse_rate(0.5, 1, 0, prior) == pytest.approx(2.0)
    assert uniform.marginal_reverse_rate(0.5, 1, 2, prior) == pytest.approx(0.5)
    marginal = prior @ uniform.kernel(0.5)
    assert [round(float(v), 4) for v in marginal] == [0.5, 0.25, 0.125, 0.125]
    assert float(marginal[0] / marginal[1]) == pytest.approx(2.0)


def test_the_rate_error_on_one_base():
    """A denoiser of (0.5, 0.3, 0.1, 0.1) gives rates 2.76 and 1.16 and a rate error of 0.754."""
    denoiser = torch.tensor([[[0.5, 0.3, 0.1, 0.1]]], dtype=torch.float64)
    current = torch.tensor([[1]])
    alpha = torch.tensor([[0.5]], dtype=torch.float64)
    rates = uniform.model_rates(denoiser, uniform.ratio_table(current, alpha))[0, 0]
    rates = rates / (uniform.BETA / 4)  # Quote the rates in the chapter's unit forward rate.
    assert [round(float(v), 4) for v in rates] == [2.76, 1.0, 1.16, 1.16]
    target = uniform.conditional_rates(0.5, current=1, clean=0)
    error = float(uniform.rate_error(target[[0, 2, 3]], rates[[0, 2, 3]]).sum())
    assert error == pytest.approx(0.754, abs=5e-4)
    assert float(uniform.rate_error(torch.tensor(5.0), torch.tensor(2.76))) == pytest.approx(
        0.731, abs=5e-4)


def test_the_rate_error_is_minimized_at_the_posterior_average():
    """0.375 l(5, B) + 0.625 l(0.2, B) is smallest at B = 2, where it equals 1.430."""
    def mixture(b: float) -> float:
        return float(0.375 * uniform.rate_error(torch.tensor(5.0), torch.tensor(b))
                     + 0.625 * uniform.rate_error(torch.tensor(0.2), torch.tensor(b)))

    assert mixture(2.0) == pytest.approx(1.430, abs=5e-4)
    assert mixture(5.0) == pytest.approx(2.598, abs=5e-4)
    assert mixture(0.2) == pytest.approx(4.235, abs=5e-4)
    assert mixture(2.0) < min(mixture(1.9), mixture(2.1))


# %% 04. d3pm: the finite step


def test_cumulative_transition_matrix():
    """With beta_1 = 0.2 and beta_2 = 0.3 the product is 0.67 on the diagonal and 0.11 off it."""
    product = d3pm.one_step(0.2) @ d3pm.one_step(0.3)
    assert float(product[0, 0]) == pytest.approx(0.67, abs=5e-3)
    assert float(product[0, 1]) == pytest.approx(0.11, abs=5e-3)
    assert float((product - d3pm.cumulative(0.56)).abs().max()) == pytest.approx(0.0, abs=1e-6)
    assert (1.0 - 1.0 / 1000) ** 1000 == pytest.approx(0.3677, abs=5e-5)


def test_reversing_one_finite_step():
    """We observe C under a clean A and get (0.5795, 0.3523, 0.0341, 0.0341) at the earlier step."""
    first, second = d3pm.one_step(0.2), d3pm.one_step(0.3)
    clean_a = torch.tensor([[[1.0, 0.0, 0.0, 0.0]]])
    posterior = d3pm.exact_posterior(torch.tensor([[1]]), clean_a, second[None], first[None])[0, 0]
    assert [round(float(v), 4) for v in posterior] == [0.5795, 0.3523, 0.0341, 0.0341]


def test_rate_matrices_chosen_for_the_data():
    """The Gaussian row is 0.4088 to a neighbour, and exp(0.3 R) has first row (0.6747, ...)."""
    row = d3pm.gaussian_row()
    assert float(row[1]) == pytest.approx(0.4088, abs=5e-5)
    assert float(row[0]) == pytest.approx(0.0912, abs=5e-5)
    adjacency = torch.tensor([[0.0, 1.0, 0.5, 0.0], [1.0, 0.0, 1.0, 0.0],
                              [0.5, 1.0, 0.0, 1.0], [0.0, 0.0, 1.0, 0.0]])
    R = d3pm.graph_rates(adjacency)
    assert [round(float(v), 4) for v in R[0]] == [-1.5, 1.0, 0.5, 0.0]
    kernel = torch.matrix_exp(0.3 * R)
    assert [round(float(v), 4) for v in kernel[0]] == [0.6747, 0.1964, 0.1122, 0.0167]


# %% 05. sedd: score entropy


def test_score_entropy_at_three_predictions():
    """With the target ratio 2, a prediction of 1.8 costs 0.0107 nats and 1.0 costs 0.3863."""
    target = torch.tensor(2.0)
    assert float(sedd.score_entropy(target, torch.tensor(1.8))) == pytest.approx(0.0107, abs=5e-5)
    assert float(sedd.score_entropy(target, torch.tensor(1.0))) == pytest.approx(0.3863, abs=5e-5)
    assert float(sedd.score_entropy(target, torch.tensor(3.0))) == pytest.approx(0.1891, abs=5e-5)
    assert float(sedd.score_entropy(target, target)) == pytest.approx(0.0, abs=1e-6)


def test_the_target_ratio_from_the_denoiser_route():
    """0.375(5) + 0.625(0.2) is 2, and the kernel ratio reproduces it at a clean A."""
    assert 0.375 * 5.0 + 0.625 * 0.2 == pytest.approx(2.0)
    x0 = torch.tensor([[0]])  # A clean A.
    z = torch.tensor([[1]])  # Observed as a C.
    ratio = sedd.kernel_ratio(x0, z, torch.tensor([[0.5]]))
    assert float(ratio[0, 0, 0]) == pytest.approx(5.0)
    assert float(ratio[0, 0, 2]) == pytest.approx(1.0)


# %% 06. block: the dial between diffusion and autoregression


def test_the_worked_block_bound():
    """ACGTAC in blocks of two at t = 0.5 gives 1.8326, 0.7134 and 0.2107, summing to 2.7567."""
    weight = 2.0
    contributions = [weight * (-math.log(0.5) - math.log(0.8)),
                     weight * -math.log(0.7), weight * -math.log(0.9)]
    assert [round(v, 4) for v in contributions] == [1.8326, 0.7133, 0.2107]
    assert sum(contributions) == pytest.approx(2.7567, abs=5e-5)


def test_the_one_position_identity():
    """At blocks of one position the time integral of w(t)(1 - alpha_t) is exactly one."""
    assert block.one_position_integral() == pytest.approx(1.0, abs=1e-9)
    generator = torch.Generator().manual_seed(0)
    estimate = block.one_position_estimate(0.7, 40000, generator)
    assert estimate == pytest.approx(-math.log(0.7), abs=0.05)


def test_block_input_keeps_the_prefix_and_hides_the_future():
    """The prefix stays clean, the current block is corrupted, and the suffix is all mask."""
    x0 = torch.arange(8).remainder(4)[None, :].repeat(2, 1)  # [2, 8].
    z = block.block_input(x0, torch.tensor([1, 1]), 2, torch.full((2, 1), 1.0))
    assert bool((z[:, :2] == x0[:, :2]).all())  # The prefix is clean.
    assert bool((z[:, 2:4] == block.MASK).all())  # The block is fully masked at t = 1.
    assert bool((z[:, 4:] == block.MASK).all())  # The future is hidden.


# %% 07. guidance: the h-transform and the worked guided jump


def test_h_transform_keeps_a_rate_matrix():
    """When we reweight by h(y)/h(x) the row sums stay at zero, and a constant h changes nothing."""
    R = torch.ones(4, 4) - torch.diag(torch.full((4,), 4.0))
    transformed = guidance.h_transform(R, torch.tensor([2.0, 1.0, 0.5, 0.5]))
    assert float(transformed.sum(dim=1).abs().max()) == pytest.approx(0.0, abs=1e-6)
    constant = guidance.h_transform(R, torch.full((4,), 3.0))
    assert float((constant - R).abs().max()) == pytest.approx(0.0, abs=1e-6)


def test_the_reverse_process_as_an_h_transform():
    """h_t = p_t/pi is (2, 1, 0.5, 0.5), and the rates out of C come back as 2, 0.5 and 0.5."""
    pi = torch.full((4,), 0.25)
    values = guidance.value_from_marginal(torch.tensor([0.5, 0.25, 0.125, 0.125]), pi)
    assert [round(float(v), 4) for v in values] == [2.0, 1.0, 0.5, 0.5]
    R = torch.ones(4, 4) - torch.diag(torch.full((4,), 4.0))
    reverse = guidance.h_transform(R, values)
    assert float(reverse[1, 0]) == pytest.approx(2.0, abs=1e-5)
    assert float(reverse[1, 2]) == pytest.approx(0.5, abs=1e-5)
    clean_a = guidance.value_from_marginal(torch.tensor([0.625, 0.125, 0.125, 0.125]), pi)
    assert float(clean_a[0] / clean_a[1]) == pytest.approx(5.0)
    assert float(clean_a[1] / clean_a[0]) == pytest.approx(0.2)


def test_the_worked_guided_jump():
    """The classifier turns the rates 2, 0.5, 0.5 into 1, 1.5, 0.5, and gamma = 2 sharpens them."""
    unguided = torch.tensor([2.0, 0.5, 0.5])
    ratio = torch.tensor([0.1, 0.6, 0.2]) / 0.2
    guided = unguided * ratio
    assert [round(float(v), 4) for v in guided] == [1.0, 1.5, 0.5]
    assert float(guided.sum()) == pytest.approx(3.0)
    shares = guided / guided.sum()
    assert [float(v) for v in shares] == pytest.approx([1 / 3, 1 / 2, 1 / 6], abs=1e-6)
    sharper = unguided * ratio ** 2
    assert [round(float(v), 4) for v in sharper] == [0.5, 4.5, 0.5]
    assert float(sharper.sum()) == pytest.approx(5.5)
    assert float((sharper / sharper.sum())[1]) == pytest.approx(0.8182, abs=5e-5)
    assert 1.0 / float(sharper.sum()) == pytest.approx(0.1818, abs=5e-5)


def test_exact_and_gradient_ratios_agree_at_first_order():
    """Both estimates of p(c | z^{i <- a})/p(c | z) have the right shape and a positive value."""
    torch.manual_seed(0)
    classifier = guidance.NoisyClassifier(6, width=16)
    z = torch.tensor([[0, guidance.MASK, 2, guidance.MASK, 1, 3]])
    t = torch.tensor([0.5])
    with torch.no_grad():
        exact = guidance.exact_ratio(classifier, z, t)
    approximate = guidance.gradient_ratio(classifier, z, t)
    assert exact.shape == approximate.shape == (1, 6, 4)
    assert bool((exact > 0).all()) and bool((approximate > 0).all())
    # The ratio at the token already in place is one under both estimates.
    assert float(exact[0, 0, 0]) == pytest.approx(1.0, abs=1e-5)
    assert float(approximate[0, 0, 0]) == pytest.approx(1.0, abs=1e-5)


# %% 08. planner: the order of the reveals and the weights in the loss


def test_the_worked_planner_selection():
    """Scores (0.42, 0.91, 0.55, 0.78) at the masked positions reveal 3 and 6, and remask 5."""
    scores = torch.tensor([[0.42, 0.88, 0.91, 0.55, 0.31, 0.78]])
    masked = torch.tensor([[True, False, True, True, False, True]])
    revealed = sorted(int(i) + 1 for i in planner.topk_where(scores, masked, 2).nonzero()[:, 1])
    returned = sorted(int(i) + 1 for i in planner.topk_where(-scores, ~masked, 1).nonzero()[:, 1])
    assert revealed == [3, 6]
    assert returned == [5]
    assert int(planner.topk_where(scores, masked, 0).sum()) == 0


def test_the_corrector_fluxes_match():
    """alpha_t beta(t) and (1 - alpha_t) w(t) both equal -alpha_dot, or one here."""
    for t in (0.5, 0.25, 0.75):
        forward, reverse = planner.corrector_fluxes(t)
        assert forward == pytest.approx(1.0)
        assert reverse == pytest.approx(1.0)


def test_the_worked_papl_weights():
    """A planner distribution of (0.10, 0.50, 0.15, 0.25) turns 2.9 nats into 2.12."""
    rho = torch.tensor([[0.10, 0.50, 0.15, 0.25]])
    cross_entropies = torch.tensor([[1.2, 0.3, 0.9, 0.5]])
    lam = 4.0 * rho
    assert [round(float(v), 4) for v in lam[0]] == [0.4, 2.0, 0.6, 1.0]
    assert float(lam.sum()) == pytest.approx(4.0)
    assert float(cross_entropies.sum()) == pytest.approx(2.9)
    assert float((lam * cross_entropies).sum()) == pytest.approx(2.12)


def test_planner_weights_average_to_one_and_reduce_to_mdlm():
    """lambda_i averages to one over the masked positions, and strength zero gives every weight one."""
    score = torch.tensor([[0.9, 0.1, 0.5, 0.2]])
    masked = torch.tensor([[True, True, False, True]])
    weights = planner.planner_weights(score, masked, 1.0)
    assert float((weights * masked).sum()) == pytest.approx(float(masked.sum()), abs=1e-5)
    plain = planner.planner_weights(score, masked, 0.0)
    assert float((plain - 1.0).abs().max()) == pytest.approx(0.0, abs=1e-6)


# %% 09. simplex: the Dirichlet belief and the thinning representation


def test_the_worked_dirichlet_parameters():
    """At alpha_t = 1/2 the parameters are (5, 1, 1, 1) and at alpha_s = 0.8 they are (17, 1, 1, 1)."""
    assert simplex.concentration(0.5) == pytest.approx(8.0)
    assert simplex.concentration(0.8) == pytest.approx(20.0)
    mean_t = simplex.belief_mean(0.5, 0)
    assert [round(float(v), 4) for v in mean_t] == [0.625, 0.125, 0.125, 0.125]
    assert [round(float(v), 4) for v in simplex.belief_mean(0.8, 0)] == [0.85, 0.05, 0.05, 0.05]
    at_t = simplex.dirichlet_parameters(0.5, 0)
    at_s = simplex.dirichlet_parameters(0.8, 0)
    assert [round(float(v), 4) for v in at_t] == [5.0, 1.0, 1.0, 1.0]
    assert [round(float(v), 4) for v in at_s] == [17.0, 1.0, 1.0, 1.0]
    total = at_t.sum()
    deviation = (at_t * (total - at_t) / (total ** 2 * (total + 1))).sqrt()
    assert float(deviation[0]) == pytest.approx(0.161, abs=5e-4)


def test_the_increment_sits_on_the_clean_coordinate():
    """The increment is (12, 0, 0, 0), so the innovation is the one-hot vector of the clean base."""
    step = simplex.increment(0.5, 0.8, 0)
    assert [round(float(v), 4) for v in step] == [12.0, 0.0, 0.0, 0.0]
    first, second = simplex.thinning_law(0.5, 0.8)
    assert (round(first, 4), round(second, 4)) == (8.0, 12.0)
    weight = first / (first + second)
    assert weight == pytest.approx(0.4)
    mixed = weight * simplex.belief_mean(0.5, 0) + (1.0 - weight) * torch.eye(4)[0]
    assert [round(float(v), 4) for v in mixed] == [0.85, 0.05, 0.05, 0.05]


def test_the_thinning_representation_reproduces_the_dirichlet():
    """W V + (1 - W) onehot(A) has the mean and the spread of Dir(17, 1, 1, 1)."""
    torch.manual_seed(0)
    at_t, at_s = simplex.dirichlet_parameters(0.5, 0), simplex.dirichlet_parameters(0.8, 0)
    first, second = simplex.thinning_law(0.5, 0.8)
    draws = 200_000
    thinned = torch.distributions.Dirichlet(at_t).sample((draws,))
    weight = torch.distributions.Beta(torch.tensor(first), torch.tensor(second)).sample((draws,))
    drawn = weight[:, None] * thinned + (1.0 - weight[:, None]) * torch.eye(4)[0]
    total = at_s.sum()
    exact = (at_s * (total - at_s) / (total ** 2 * (total + 1))).sqrt()
    assert [round(float(v), 4) for v in exact] == [0.0779, 0.0476, 0.0476, 0.0476]
    assert float(drawn.mean(0)[0]) == pytest.approx(0.85, abs=2e-3)
    assert float(drawn.std(0)[0]) == pytest.approx(float(exact[0]), abs=2e-3)


def test_the_parameter_field_and_the_reverse_step():
    """The batched parameters match the per-position ones, and one step stays on the simplex."""
    x0 = torch.tensor([[0, 1], [2, 3]])
    field = simplex.parameter_field(x0, torch.full((2, 1), 0.5))
    assert [round(float(v), 4) for v in field[0, 0]] == [5.0, 1.0, 1.0, 1.0]
    assert [round(float(v), 4) for v in field[1, 1]] == [1.0, 1.0, 1.0, 5.0]
    torch.manual_seed(0)
    belief = simplex.corrupt(x0, torch.full((2, 1), 0.5))
    assert belief.shape == (2, 2, 4)
    assert float(belief.sum(-1).sub(1.0).abs().max()) == pytest.approx(0.0, abs=1e-6)
    moved = simplex.reverse_step(belief, x0, 0.5, 0.8)
    assert float(moved.sum(-1).sub(1.0).abs().max()) == pytest.approx(0.0, abs=1e-6)
    assert bool((moved >= 0).all())


def test_a_one_hot_belief_recovers_the_token_denoiser():
    """The architecture changes only at the input, so one-hot beliefs give identical logits."""
    torch.manual_seed(0)
    model = simplex.BeliefDenoiser(6, width=16)
    tokens = torch.tensor([[0, 1, 2, 3, 0, 1]])
    t = torch.tensor([0.4])
    one_hot = torch.nn.functional.one_hot(tokens, 4).to(torch.get_default_dtype())
    with torch.no_grad():
        through_belief = model(one_hot, t)
        through_tokens = model.inner(tokens, t)
    assert float((through_belief - through_tokens).abs().max()) == pytest.approx(0.0, abs=1e-6)
    drawn = simplex.sample(model, 3, 6, steps=4)
    assert drawn.shape == (3, 6) and int(drawn.max()) < 4


# %% 10. search: rollouts, the archive and the two objectives


def test_the_worked_value_estimate():
    """Rollouts averaging 0.35 and 0.75 reweight a rate of 1.2 to 2.571."""
    parent = float(torch.tensor([0.1, 0.8, 0.3, 0.2]).mean())
    child = float(torch.tensor([0.7, 0.9, 0.6, 0.8]).mean())
    assert parent == pytest.approx(0.35)
    assert child == pytest.approx(0.75)
    assert child / parent == pytest.approx(2.143, abs=5e-4)
    assert 1.2 * child / parent == pytest.approx(2.571, abs=5e-4)
    assert 0.2 / parent == pytest.approx(0.571, abs=5e-4)


def test_the_worked_pareto_archive():
    """P3 at (0.4, 0.2) is dominated by P1 at (0.8, 0.3), and P1 and P2 both stay."""
    archive = search.ParetoArchive()
    for name, scores in (((1,), (0.8, 0.3)), ((2,), (0.5, 0.7)), ((3,), (0.4, 0.2))):
        archive.add(name, scores)
    assert sorted(q for q, _ in archive.entries) == [(1,), (2,)]
    archive.add((4,), (0.9, 0.9))  # A candidate that dominates both of them.
    assert [q for q, _ in archive.entries] == [(4,)]


def test_the_two_objectives():
    """A GC-free sequence scores zero on the first axis, and the motif scores one on the second."""
    tokens = torch.tensor([[0, 1, 2, 3, 0, 0], [0, 0, 3, 3, 0, 3]])
    scores = search.objectives(tokens)
    assert float(scores[0, 0]) == pytest.approx(2 / 6)
    assert float(scores[0, 1]) == pytest.approx(1.0)  # ACGT sits at the start.
    assert float(scores[1, 0]) == pytest.approx(0.0)
    assert float(scores[1, 1]) < 1.0