"""Phase 5 acceptance for td.data. See PLAN.md. The load-bearing test is that values land in the *right cells*, checked against an independently built reference. A shape check would pass just as happily on a mis-shuffled lattice, which is the exact failure this layer exists to prevent. """ import pytest import torch from torch.utils.data import DataLoader import torch_dimensions as td from torch_dimensions.data import ( LatticeDataset, LatticeSource, LatticeWindow, TensorSource, collate_lattice, from_coords, from_table, ) STATES = ("CA", "NY", "TX") SKUS = ("a", "b") def rows(n_time=8, skip=(("NY", "b"),)): """Long-format rows whose value encodes its own coordinates, so a misplacement is detectable rather than merely plausible.""" coords, times, values = [], [], [] for t in range(n_time): for s in STATES: for k in SKUS: if (s, k) in skip: continue coords.append((s, k)) times.append(2000 + t) values.append([t * 100.0 + STATES.index(s) * 10.0 + SKUS.index(k)]) return coords, times, values # -- from_coords ------------------------------------------------------------- def test_vocabularies_are_sorted_and_shape_follows(): cm = from_coords([("TX", "b"), ("CA", "a"), ("CA", "b")], names=("state", "sku")) assert cm.vocabs == (("CA", "TX"), ("a", "b")) assert cm.lattice.shape == (2, 2) assert cm.lattice.axis_names == ("time", "state", "sku") def test_unobserved_combinations_are_marked_absent(): cm = from_coords([("CA", "a"), ("CA", "b"), ("TX", "a")]) assert cm.lattice.n_valid == 3 and cm.lattice.n_cells == 4 assert not cm.lattice.valid[1, 1] # TX/b never seen def test_a_fully_observed_grid_is_dense_not_a_mask_of_all_true(): cm = from_coords([(s, k) for s in STATES for k in SKUS]) assert cm.lattice.is_dense def test_dense_flag_overrides_the_inference(): cm = from_coords([("CA", "a"), ("TX", "b")], dense=True) assert cm.lattice.is_dense and cm.lattice.n_cells == 4 def test_time_axis_is_optional(): assert not from_coords([("CA", "a")], time=False).lattice.time def test_index_round_trips_through_decode(): cm = from_coords([(s, k) for s in STATES for k in SKUS], names=("state", "sku")) pairs = [(s, k) for s in STATES for k in SKUS] for row, flat in zip(pairs, cm.index.tolist(), strict=True): assert cm.decode(flat) == row def test_encode_matches_the_index_built_at_construction(): coords = [("TX", "b"), ("CA", "a"), ("NY", "b")] cm = from_coords(coords) assert torch.equal(cm.encode(coords), cm.index) def test_encode_refuses_unknown_values_rather_than_folding_them_to_zero(): cm = from_coords([("CA", "a")], names=("state", "sku")) with pytest.raises(KeyError, match="'state'"): cm.encode([("ZZ", "a")]) with pytest.raises(ValueError, match="expected 2 coordinates"): cm.encode([("CA",)]) def test_integer_coordinates_work_too(): cm = from_coords(torch.tensor([[0, 1], [2, 0]])) assert cm.lattice.shape == (2, 2) @pytest.mark.parametrize( ("coords", "match"), [([], "at least one"), ([("a", "b"), ("c",)], "same length")], ) def test_from_coords_errors(coords, match): with pytest.raises(ValueError, match=match): from_coords(coords) # -- from_table -------------------------------------------------------------- def test_values_land_in_the_cells_their_coordinates_name(): """The whole point of the layer. Checked against the encoding baked into each value, not against another call to our own mapping.""" coords, times, values = rows() table = from_table(coords, times, values, names=("state", "sku")) for t in range(len(table.times)): for si, s in enumerate(STATES): for ki, k in enumerate(SKUS): got = table.series[t, si, ki, 0].item() if (s, k) == ("NY", "b"): assert got == 0.0, "absent cell must be exactly zero" else: assert got == t * 100.0 + si * 10.0 + ki def test_absent_cells_are_zero_and_marked(): table = from_table(*rows(), names=("state", "sku")) assert table.lattice.n_valid == 5 and table.lattice.n_cells == 6 assert table.series.masked_select(~table.lattice.valid.reshape(1, 3, 2, 1)).abs().max() == 0 def test_time_axis_is_the_sorted_distinct_timestamps(): coords, times, values = rows(n_time=4) table = from_table(coords, times, values) assert table.times == (2000, 2001, 2002, 2003) assert len(table) == 4 and table.series.shape[0] == 4 def test_shape_and_feature_count(): table = from_table(*rows(), names=("state", "sku")) assert table.series.shape == (8, 3, 2, 1) assert table.n_features == 1 assert "5/6" in repr(table) def test_duplicate_time_cell_rows_are_refused(): """Keeping the last silently would turn a join bug into a plausible dataset.""" with pytest.raises(ValueError, match="duplicate"): from_table([("CA", "a"), ("CA", "a")], [2000, 2000], [[1.0], [2.0]]) def test_the_same_cell_at_different_times_is_not_a_duplicate(): table = from_table([("CA", "a"), ("CA", "a")], [2000, 2001], [[1.0], [2.0]]) assert table.series.flatten().tolist() == [1.0, 2.0] @pytest.mark.parametrize( ("kwargs", "match"), [ ({"values": [1.0, 2.0]}, r"\(N, F\)"), ({"times": [2000]}, "same number of rows"), ], ) def test_from_table_errors(kwargs, match): base = { "coords": [("CA", "a"), ("CA", "b")], "times": [2000, 2000], "values": [[1.0], [2.0]], } with pytest.raises(ValueError, match=match): from_table(**{**base, **kwargs}) # -- windowing --------------------------------------------------------------- def test_windows_tile_the_axis_with_targets_after_inputs(): w = LatticeWindow(n_time=10, input_len=3, horizon=2) assert len(w) == 6 assert w[0] == (0, 3, 3, 5) assert w[-1] == (5, 8, 8, 10) for win in w: assert win.x1 == win.y0, "a window must never see its own target" def test_stride_controls_the_step(): assert [win.x0 for win in LatticeWindow(10, 3, 0, stride=3)] == [0, 3, 6] def test_horizon_zero_gives_no_target_range(): w = LatticeWindow(6, 3) assert all(win.y0 == win.y1 for win in w) def test_split_drops_windows_straddling_the_boundary(): """The gap is deliberate: a straddling window would put post-cut timesteps inside a training input.""" w = LatticeWindow(12, 3, 1) before, after = w.split(6) assert all(win.y1 <= 6 for win in before) assert all(win.x0 >= 6 for win in after) assert len(before) + len(after) < len(w) def test_split_at_time_uses_timestamps(): w = LatticeWindow(6, 2) times = [2000, 2001, 2002, 2003, 2004, 2005] by_index = w.split(3) by_time = w.split_at_time(times, 2003) assert [list(x) for x in by_time] == [list(x) for x in by_index] def test_split_at_a_time_past_the_end_puts_everything_before(): w = LatticeWindow(6, 2) before, after = w.split_at_time([2000, 2001, 2002, 2003, 2004, 2005], 2099) assert len(before) == len(w) and len(after) == 0 def test_slicing_preserves_the_window_type(): w = LatticeWindow(10, 3) assert isinstance(w[:2], LatticeWindow) and len(w[:2]) == 2 @pytest.mark.parametrize( ("kwargs", "match"), [ ({"input_len": 0}, "input_len"), ({"horizon": -1}, "horizon"), ({"stride": 0}, "stride"), ({"input_len": 9, "horizon": 5}, "exceeds"), ], ) def test_window_errors(kwargs, match): with pytest.raises(ValueError, match=match): LatticeWindow(**{"n_time": 10, "input_len": 3, **kwargs}) # -- source / dataset / collate ---------------------------------------------- def test_tensor_source_satisfies_the_protocol(): table = from_table(*rows(), names=("state", "sku")) src = TensorSource(table.series, table.lattice) assert isinstance(src, LatticeSource) assert len(src) == 8 and src[0:3].shape == (3, 3, 2, 1) def test_source_rejects_a_series_that_does_not_match_the_lattice(): table = from_table(*rows(), names=("state", "sku")) with pytest.raises(ValueError, match="lattice dims"): TensorSource(torch.zeros(8, 9, 9, 1), table.lattice) def test_a_custom_source_needs_no_inheritance(): """The protocol is the customization story — this class knows nothing about the library.""" table = from_table(*rows(), names=("state", "sku")) class Custom: def __init__(self, series, lattice): self._s, self._l = series, lattice @property def lattice(self): return self._l def __len__(self): return self._s.shape[0] def __getitem__(self, sl): return self._s[sl] ds = LatticeDataset(Custom(table.series, table.lattice), LatticeWindow(8, 3, 1)) assert ds[0]["x"].shape == (3, 3, 2, 1) def test_dataset_yields_inputs_and_targets_without_the_lattice(): table = from_table(*rows(), names=("state", "sku")) ds = LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) sample = ds[0] assert set(sample) == {"x", "y", "window"}, "the lattice is static, not per-sample" assert sample.x.shape == (3, 3, 2, 1) and sample.y.shape == (1, 3, 2, 1) assert ds.lattice is table.lattice def test_dataset_refuses_windows_that_run_past_the_source(): table = from_table(*rows(n_time=4), names=("state", "sku")) with pytest.raises(ValueError, match="past the end"): LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) def test_dataset_refuses_an_empty_window_set(): table = from_table(*rows(), names=("state", "sku")) empty = LatticeWindow(8, 3, 1).split(0)[0] with pytest.raises(ValueError, match="empty"): LatticeDataset(TensorSource(table.series, table.lattice), empty) def test_collate_stacks_and_keeps_the_lattice_out(): table = from_table(*rows(), names=("state", "sku")) ds = LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) batch = collate_lattice([ds[0], ds[1], ds[2]]) assert batch.x.shape == (3, 3, 3, 2, 1) assert batch.y.shape == (3, 1, 3, 2, 1) assert "lattice" not in batch and len(batch["windows"]) == 3 def test_collate_refuses_ragged_windows_rather_than_padding(): table = from_table(*rows(), names=("state", "sku")) ds = LatticeDataset(TensorSource(table.series, table.lattice), LatticeWindow(8, 3, 1)) a = ds[0] b = dict(a) b["x"] = a["x"][:2] with pytest.raises(ValueError, match="differing input lengths"): collate_lattice([a, b]) def test_collate_refuses_an_empty_batch(): with pytest.raises(ValueError, match="empty"): collate_lattice([]) # -- end to end -------------------------------------------------------------- def test_table_to_dataloader_to_model_to_backward(): table = from_table(*rows(n_time=16), names=("state", "sku")) windows = LatticeWindow(len(table), input_len=4, horizon=1) train, _ = windows.split(10) ds = LatticeDataset(TensorSource(table.series, table.lattice), train) dl = DataLoader(ds, batch_size=2, shuffle=True, collate_fn=collate_lattice) model = td.LSTM(d_model=6, n_layers=3, lattice=table.lattice, d_input=table.n_features) batch = next(iter(dl)) out = model(batch.x) assert out.shape == (2, 4, 3, 2, 6) out.pow(2).mean().backward() assert all(p.grad is not None for p in model.parameters()) def test_d_input_is_optional_when_the_data_is_already_d_model_wide(): lat = td.Lattice(shape=(2, 3), time=True) model = td.LSTM(d_model=5, n_layers=3, lattice=lat) assert model(torch.randn(1, 4, 2, 3, 5)).shape == (1, 4, 2, 3, 5) # -- multiprocessing safety --------------------------------------------------- def _small_dataset(): lat = td.Lattice(shape=(2, 3), time=True) source = TensorSource(torch.randn(8, 2, 3, 4), lat) return LatticeDataset(source, LatticeWindow(8, input_len=3, horizon=1)) def test_samples_and_batches_survive_pickling(): """DataLoader workers send every Sample — and the collated Batch — through a pickled queue. `__getattr__ = dict.__getitem__` broke that: pickle probes optional dunders with getattr and tolerates AttributeError, not the KeyError a dict lookup raises. num_workers>0 crashed outright.""" import pickle sample = _small_dataset()[0] back = pickle.loads(pickle.dumps(sample)) assert torch.equal(back.x, sample.x) and torch.equal(back.y, sample.y) batch = collate_lattice([sample, sample]) back = pickle.loads(pickle.dumps(batch)) assert torch.equal(back.x, batch.x) def test_a_missing_field_reads_as_absent_not_as_a_keyerror(): """`getattr(sample, "y", None)` and `hasattr` must behave; a horizon-0 sample simply has no target.""" lat = td.Lattice(shape=(2, 3), time=True) source = TensorSource(torch.randn(8, 2, 3, 4), lat) sample = LatticeDataset(source, LatticeWindow(8, input_len=3, horizon=0))[0] assert getattr(sample, "y", None) is None assert not hasattr(sample, "y") with pytest.raises(AttributeError): _ = sample.y def test_dataloader_with_worker_processes(): """The end-to-end form of the pickling guarantee: real worker processes, real queues. This is the configuration every user with a large dataset reaches for first.""" dl = DataLoader(_small_dataset(), batch_size=2, num_workers=2, collate_fn=collate_lattice) batch = next(iter(dl)) assert batch.x.shape == (2, 3, 2, 3, 4) and batch.y.shape == (2, 1, 2, 3, 4) def test_collate_refuses_mixed_target_presence(): """Keying off samples[0] silently dropped every target whenever the first sample happened to lack one.""" from torch_dimensions.data.source import Sample a = Sample(x=torch.zeros(3, 2), window=None) b = Sample(x=torch.zeros(3, 2), y=torch.ones(1, 2), window=None) with pytest.raises(ValueError, match="mixed-horizon"): collate_lattice([a, b]) with pytest.raises(ValueError, match="mixed-horizon"): collate_lattice([b, a]) def test_split_at_time_refuses_unsorted_times(): """An unsorted timestamp list used to produce a silently nonsensical split — the quietest possible leakage bug.""" w = LatticeWindow(6, input_len=2, horizon=1) with pytest.raises(ValueError, match="sorted"): w.split_at_time([3, 1, 2, 5, 4, 6], 4) # -- on-disk source and masked normalization ---------------------------------- # `.npy` is numpy's container; torch does not require numpy, and this project's # own CI is an install without it. numpy = pytest.importorskip("numpy") def test_memmap_source_passes_the_source_conformance_check(tmp_path): lat = td.Lattice(shape=(3, 4), names=("h", "w")) series = torch.randn(20, 3, 4, 2) path = td.data.MemmapSource.write(tmp_path / "series.npy", series) source = td.data.MemmapSource(path, lat) report = td.testing.check_data_source(source) assert report, str(report) assert torch.allclose(source[0:20], series, atol=1e-6) def test_a_memmap_source_reaches_a_worker_process(tmp_path): """The failure this class exists to demonstrate: a live mmap handle either fails to pickle or pickles into something invalid in the child, and under DataLoader(num_workers>0) that presents as a hang (DEBUG.md #9).""" import pickle lat = td.Lattice(shape=(2, 2), names=("a", "b")) series = torch.randn(6, 2, 2, 1) path = td.data.MemmapSource.write(tmp_path / "s.npy", series) source = td.data.MemmapSource(path, lat) _ = source[0:2] # force the handle open *before* pickling revived = pickle.loads(pickle.dumps(source)) assert revived._array is None, "the mmap handle travelled into the pickle" assert torch.allclose(revived[0:6], source[0:6]) def test_a_memmap_source_refuses_a_file_that_is_not_its_lattice(tmp_path): path = td.data.MemmapSource.write(tmp_path / "s.npy", torch.randn(5, 9, 9, 1)) with pytest.raises(ValueError, match="lattice dims"): td.data.MemmapSource(path, td.Lattice(shape=(3, 4), names=("h", "w"))) def test_masked_stats_ignore_absent_cells(): """A mean over a sparse lattice's structural zeros is dragged toward zero in proportion to the sparsity, and nothing about the model then looks wrong.""" valid = torch.tensor([[True, False], [True, True]]) lat = td.Lattice(shape=(2, 2), names=("a", "b"), valid=valid) series = torch.full((10, 2, 2, 1), 5.0) series[:, 0, 1] = 0.0 # the absent cell, zero as the library guarantees naive = series.mean() stats = td.data.masked_stats(series, lat) assert abs(float(naive) - 3.75) < 1e-5, "the naive mean is dragged toward zero" present = stats.mean.reshape(-1)[[0, 2, 3]] assert torch.allclose(present, torch.full((3,), 5.0)), stats.mean def test_normalizer_round_trips(): lat = td.Lattice(shape=(2, 3), names=("a", "b")) series = torch.randn(30, 2, 3, 2) * 4 + 7 stats = td.data.masked_stats(series, lat) assert torch.allclose(stats.invert(stats.apply(series)), series, atol=1e-4) normalized = stats.apply(series) assert normalized.mean().abs() < 0.1 and abs(float(normalized.std()) - 1) < 0.2 def test_masked_stats_treat_nan_as_absent(): lat = td.Lattice(shape=(2,), names=("a",)) series = torch.full((8, 2, 1), 3.0) series[0:4, 0] = float("nan") stats = td.data.masked_stats(series, lat) assert torch.isfinite(stats.mean).all() assert abs(float(stats.mean.reshape(-1)[0]) - 3.0) < 1e-5