File size: 12,205 Bytes
ecc81b3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Phase 4 acceptance for td.testing.check_block. See PLAN.md.

Half of this file is deliberately broken blocks. A conformance suite that only
ever passes proves nothing, so every check gets a block built to violate it and
nothing else.
"""

import pytest
import torch
import torch.nn as nn

import torch_dimensions as td
from torch_dimensions import GRU, LSTM, Lattice, axial_apply


def rnn_factory(cls, n_layers=None):
    def build(lat, d_model, plan=None):
        return cls(d_model, n_layers or len(lat.axis_names), lat, plan=plan)

    return build


def one_layer_reference(block, x):
    """Pre-norm residual around the single mixer — what a 1-layer stack is."""
    return x + block.nd.mixers[0].rnn(block.nd.norms[0](x))[0]


def status(report, fragment):
    for r in report.results:
        if fragment in r.name:
            return r.status
    raise KeyError(f"no check named ~{fragment!r} in:\n{report}")


# -- the library's own blocks conform ----------------------------------------


@pytest.mark.parametrize("cls", [LSTM, GRU])
def test_rnn_family_passes_every_applicable_check(cls):
    report = td.testing.check_block(rnn_factory(cls))
    assert report, str(report)
    assert not report.failed


def test_rank_one_equivalence_passes_when_a_reference_is_given():
    report = td.testing.check_block(rnn_factory(LSTM), reference=one_layer_reference)
    assert status(report, "rank-1") == "pass", str(report)


def test_checks_that_cannot_run_are_reported_as_skipped_not_passed():
    """A skipped check must never read as a passing one."""
    report = td.testing.check_block(rnn_factory(LSTM))
    assert status(report, "rank-1") == "skip"  # no reference supplied
    assert status(report, "Kronecker") == "skip"  # Phase 6
    assert status(report, "compile") == "skip"  # off by default
    assert report, "skips must not make the report falsy"


def test_covariance_is_skipped_when_the_factory_cannot_fix_the_plan():
    def no_plan_arg(lat, d_model):
        return LSTM(d_model, len(lat.axis_names), lat)

    report = td.testing.check_block(no_plan_arg)
    assert status(report, "covariant") == "skip"


# -- deliberately broken blocks ----------------------------------------------


class _Base(nn.Module):
    def __init__(self, lat, d_model):
        super().__init__()
        self.lat, self.lin = lat, nn.Linear(d_model, d_model)


class ShapeBreaker(_Base):
    def forward(self, x):
        return self.lin(x)[..., :1]


class LeakyMask(_Base):
    """Scans without zeroing absent cells, so their values ride the recurrence
    into present ones."""

    def forward(self, x):
        return axial_apply(self.lin(x), self.lat, 0, lambda s: s.cumsum(1))


class HardcodedAxis(_Base):
    """Always sweeps tensor dim 1 instead of resolving the axis, so the result
    depends on which axis happens to be stored first."""

    def forward(self, x):
        return self.lin(x).cumsum(dim=1)


class DeadParameter(_Base):
    def __init__(self, lat, d_model):
        super().__init__(lat, d_model)
        self.unused = nn.Parameter(torch.zeros(d_model))

    def forward(self, x):
        return self.lin(x)


def _factory(cls):
    def build(lat, d_model, plan=None):
        return cls(lat, d_model)

    return build


def test_shape_check_catches_a_block_that_changes_width():
    report = td.testing.check_block(_factory(ShapeBreaker), raise_on_failure=False)
    assert status(report, "shape") == "fail"
    assert not report


def test_mask_check_catches_absent_cells_leaking_into_the_output():
    report = td.testing.check_block(_factory(LeakyMask), raise_on_failure=False)
    assert status(report, "absent cells") == "fail"


def test_covariance_check_catches_a_hardcoded_axis():
    report = td.testing.check_block(_factory(HardcodedAxis), ranks=(3,), raise_on_failure=False)
    assert status(report, "covariant") == "fail"


def test_gradient_check_catches_a_parameter_that_never_receives_grad():
    report = td.testing.check_block(_factory(DeadParameter), raise_on_failure=False)
    assert status(report, "gradients") == "fail"
    assert "unused" in [r for r in report.results if "gradients" in r.name][0].detail


def test_equivalence_check_catches_a_wrong_rank_one_result():
    report = td.testing.check_block(
        rnn_factory(LSTM),
        reference=lambda block, x: torch.zeros_like(x),
        raise_on_failure=False,
    )
    assert status(report, "rank-1") == "fail"


# -- reporting ---------------------------------------------------------------


def test_failures_raise_by_default_with_the_report_attached():
    with pytest.raises(AssertionError, match="conformance check failed"):
        td.testing.check_block(_factory(ShapeBreaker))


def test_report_renders_every_check():
    text = str(td.testing.check_block(rnn_factory(LSTM)))
    assert text.count("\n") == 6  # seven checks
    assert "[  ok]" in text and "[skip]" in text


def test_report_is_falsy_only_when_something_failed():
    assert td.testing.check_block(rnn_factory(LSTM))
    assert not td.testing.check_block(_factory(ShapeBreaker), raise_on_failure=False)


def test_compile_check_runs_when_asked():
    report = td.testing.check_block(
        rnn_factory(LSTM, n_layers=1), ranks=(2,), check_compile=True, raise_on_failure=False
    )
    assert status(report, "compile") in {"pass", "fail"}


def test_sparse_can_be_turned_off_for_blocks_that_do_not_support_it():
    report = td.testing.check_block(rnn_factory(LSTM), sparse=False)
    assert status(report, "absent cells") == "skip"


def test_time_axis_lattices_are_supported():
    report = td.testing.check_block(rnn_factory(LSTM), time=True, ranks=(1, 2))
    assert report, str(report)


def test_custom_nd_method_can_be_checked():
    """The point of shipping this: a user's own strategy gets the same checks."""

    def reversed_sweep(mixer, plan, lattice, d_model, **kw):
        flipped = td.ScanPlan.from_list([(s.axis, not s.reverse) for s in plan])
        return td.AxialScan(mixer=mixer, plan=flipped, lattice=lattice, d_model=d_model, **kw)

    def build(lat, d_model, plan=None):
        return LSTM(d_model, len(lat.axis_names), lat, plan=plan, nd_method=reversed_sweep)

    assert td.testing.check_block(build)


def test_lattice_helper_builds_a_genuinely_sparse_mask():
    lat = td.testing._lattice(3, sparse=True, seed=0)
    assert 0 < lat.n_valid < lat.n_cells
    assert isinstance(lat, Lattice)


def test_checks_run_only_at_ranks_the_caller_requested():
    """The gradient check hardcoded rank 2 "for speed", so ranks=(3, 4)
    gradchecked a rank-2 block the factory never claimed to support — and a
    factory valid only at its stated ranks failed a check it should pass."""

    def rank3_only(lat, d_model):
        assert lat.rank in (3, 4), f"built at unrequested rank {lat.rank}"
        plan = td.ScanPlan.cyclic(lat.axis_names, lat.n_axes)
        return td.AxialScan(
            mixer=lambda: torch.nn.Linear(d_model, d_model),
            plan=plan,
            lattice=lat,
            d_model=d_model,
        )

    report = td.testing.check_block(
        rank3_only,
        ranks=(3, 4),
        # A reference forces the equivalence check to decide; it must skip
        # (its claim is rank-1 and rank 1 was not requested), not build a
        # rank-1 lattice the factory refuses.
        reference=lambda block, x: block(x),
        raise_on_failure=False,
    )
    assert not report.failed, str(report)
    assert any("rank 1" in r.detail for r in report.skipped), str(report)


# -- high ranks --------------------------------------------------------------


@pytest.mark.parametrize("rank", [5, 6])
def test_the_scan_family_conforms_at_ranks_five_and_six(rank):
    """The machinery is rank-generic by construction; that is a claim, and this
    is the test of it. Ranks 5-6 were shipped untested for one release — the
    README said so — because "generic" felt like enough. It is not: a rank-6
    lattice is 5,040 cells and every axis bug that hides behind a lucky square
    shape at rank 2 has room to show here."""
    report = td.testing.check_block(rnn_factory(LSTM, n_layers=3), ranks=(rank,), d_model=2)
    assert report, str(report)


@pytest.mark.parametrize("rank", [5, 6])
def test_the_kernel_family_conforms_at_ranks_five_and_six(rank):
    def build(lat, d_model, plan=None):
        return td.AxialKernel(
            mixer=None,
            plan=plan or td.ScanPlan.cyclic(lat.axis_names, len(lat.axis_names)),
            lattice=lat,
            d_model=d_model,
        )

    report = td.testing.check_block(build, ranks=(rank,), d_model=2)
    assert report, str(report)


def test_a_rank_five_model_trains():
    """Conformance says the plumbing is right; this says the thing still learns
    when five axes have to be reached through three layers."""
    lat = td.Lattice(shape=(2, 2, 3, 2, 2), names=("a", "b", "c", "d", "e"))
    model = td.LSTM(d_model=8, n_layers=5, lattice=lat, d_input=1)
    x = torch.randn(4, *lat.shape, 1)
    y = x.cumsum(dim=lat.tensor_dim("c"))
    head = nn.Linear(8, 1)
    opt = torch.optim.Adam([*model.parameters(), *head.parameters()], lr=3e-2)
    first = last = None
    for step in range(60):
        loss = (head(model(x)) - y).pow(2).mean()
        opt.zero_grad()
        loss.backward()
        opt.step()
        first = float(loss.detach()) if step == 0 else first
        last = float(loss.detach())
    assert last < first * 0.25, f"rank-5 model did not learn: {first:.4f} -> {last:.4f}"


# -- the debug mixer and the source checker ----------------------------------


def test_recorder_answers_which_axis_each_layer_swept():
    """ "Which axis did layer 3 actually sweep" is the first question every
    integration bug asks; this is the tool that answers it."""
    lat = td.Lattice(shape=(3, 7), names=("h", "w"), time=True)
    plan = td.ScanPlan.from_list([("h", False), ("w", True), ("time", False)])
    model = td.LSTM(4, lattice=lat, plan=plan, mixer=td.testing.Recorder)
    x = torch.randn(2, 5, 3, 7, 4)
    out = model(x)

    assert out.shape == x.shape
    lengths = [m.calls[0].length for m in model.nd.mixers]
    assert lengths == [3, 7, 5], f"layers swept axes of length {lengths}"
    # the folded batch is everything else
    assert model.nd.mixers[0].calls[0].lines == 2 * 5 * 7
    model.nd.mixers[0].reset()
    assert model.nd.mixers[0].calls == []


def test_recorder_leaves_the_data_alone():
    """It has to be the identity, or it cannot be dropped into a real model to
    ask a question about that model."""
    lat = td.Lattice(shape=(4,), names=("a",))
    rec = td.testing.Recorder(6)
    x = torch.randn(3, 4, 6)
    assert torch.equal(rec(x), x)
    assert lat.rank == 1


def test_check_data_source_accepts_the_shipped_source():
    source = td.data.TensorSource(
        torch.randn(9, 3, 4, 2), td.Lattice(shape=(3, 4), names=("h", "w"))
    )
    report = td.testing.check_data_source(source)
    assert report, str(report)


def test_check_data_source_catches_a_source_that_lies_about_its_shape():
    class Liar:
        lattice = td.Lattice(shape=(3, 4), names=("h", "w"))

        def __len__(self):
            return 5

        def __getitem__(self, index):
            return torch.randn(3, 9, 9, 2)  # not the declared lattice

    report = td.testing.check_data_source(Liar(), raise_on_failure=False)
    assert not report
    assert any("declared lattice" in r.name for r in report.failed)


def test_check_data_source_catches_a_source_that_cannot_reach_a_worker():
    """DEBUG.md #9: an unpicklable source does not raise under
    DataLoader(num_workers>0) — it hangs. Finding it here is the whole point."""

    class Handle:
        lattice = td.Lattice(shape=(2,), names=("a",))

        def __init__(self):
            self.data = torch.randn(4, 2, 1)
            self.lock = __import__("threading").Lock()  # unpicklable

        def __len__(self):
            return 4

        def __getitem__(self, index):
            return self.data[index]

    report = td.testing.check_data_source(Handle(), raise_on_failure=False)
    assert not report
    assert any("worker" in r.name for r in report.failed)