import numpy as np import pytest torch = pytest.importorskip("torch") from suvi import model # noqa: E402 BANDS = model.BANDS def conditioning(states, satellites=None, dts=None): """Build a (1, S, COND_DIM) conditioning tensor.""" count = len(states) satellites = satellites or [True] * count dts = dts if dts is not None else [(i - count // 2) * 240.0 for i in range(count)] rows = [ model.frame_conditioning(states[i], satellites[i], dts[i]) for i in range(count) ] return torch.stack(rows)[None] def expand(net, frames, condition, present=None): """The stack with the model's trend candidates appended, as the blend sees it.""" trends, condition, present = net._trends(frames, condition, present) return torch.cat([frames, trends], dim=1), condition, present def stack(count, size=64, seed=0): generator = torch.Generator().manual_seed(seed) return torch.randn(1, count, BANDS, size, size, generator=generator) @pytest.fixture(scope="module") def net(): torch.manual_seed(0) return model.build(base=16, depth=3).eval() # ------------------------------------------------------------------- conditioning def test_conditioning_is_the_declared_width(): assert model.frame_conditioning("available", True, 0.0).shape == (model.COND_DIM,) def test_the_layout_names_every_column_once(): assert model.COND_DIM == len(model.COND_LAYOUT) == len(set(model.COND_LAYOUT)) assert [model.AVAILABLE, model.MISSING, model.TARGET_SATELLITE, model.DT_SIGN, model.DT_LOG, model.AGREE_MEAN, model.AGREE_MAX, model.DERIVED] == list( range(model.COND_DIM)) def test_conditioning_separates_the_two_states(): available = model.frame_conditioning("available", True, 240.0) missing = model.frame_conditioning("missing", True, 240.0) assert available[model.AVAILABLE] == 1.0 and available[model.MISSING] == 0.0 assert missing[model.AVAILABLE] == 0.0 and missing[model.MISSING] == 1.0 @pytest.mark.parametrize("state", ["suspect", "probably-fine"]) def test_conditioning_rejects_an_unknown_state(state): """v9 has no `suspect`: a judged-bad band is withheld, never shown.""" with pytest.raises(KeyError): model.frame_conditioning(state, True, 0.0) def test_conditioning_distinguishes_direction_and_distance(): back = model.frame_conditioning("available", True, -7200.0) forward = model.frame_conditioning("available", True, 7200.0) far = model.frame_conditioning("available", True, 72000.0) assert back[model.DT_SIGN] == -1.0 and forward[model.DT_SIGN] == 1.0 assert back[model.DT_LOG] == pytest.approx(forward[model.DT_LOG]) # magnitude only assert far[model.DT_LOG] > forward[model.DT_LOG] def test_conditioning_leaves_agreement_to_the_model(): """The agreement columns are computed from the stack inside forward(), identically at training and inference -- the fix for detector scores that were always zero in training and populated on the bench.""" vector = model.frame_conditioning("available", True, 240.0) assert vector[model.AGREE_MEAN] == 0.0 and vector[model.AGREE_MAX] == 0.0 # ------------------------------------------------------------------- blend prior def test_prior_favours_the_nearest_frame_in_time(): condition = conditioning(["available"] * 3, satellites=[True] * 3, dts=[-240.0, -3840.0, -240.0 * 300]) prior = model.blend_prior(condition)[0] assert prior[0] > prior[1] > prior[2] def test_prior_no_longer_penalises_the_counterpart(): """The old prior down-weighted cross-satellite frames because their gain was wrong at initialisation. Candidates now arrive gain-matched by suvi.align, so a simultaneous counterpart is as trustworthy as a local frame.""" same = conditioning(["available"], satellites=[True], dts=[0.0]) cross = conditioning(["available"], satellites=[False], dts=[0.0]) assert model.blend_prior(same)[0, 0] == model.blend_prior(cross)[0, 0] def test_prior_disfavours_derived_candidates(): real = conditioning(["available"], dts=[-240.0]) derived = real.clone() derived[..., model.DERIVED] = 1.0 gap = model.blend_prior(real)[0, 0] - model.blend_prior(derived)[0, 0] assert float(gap) == pytest.approx(model.PRIOR_DERIVED_PENALTY) # ------------------------------------------------------------------------- shapes def test_forward_returns_the_source_shape(net): out = net(stack(5), conditioning(["available"] * 5)) assert out.shape == (1, BANDS, 64, 64) assert torch.isfinite(out).all() @pytest.mark.parametrize("size", [160, 320, 640]) def test_one_model_serves_every_source_resolution(net, size): """Blend and polish are fields: they upsample, so 1280 needs no retraining.""" sources = torch.randn(1, 3, BANDS, size, size) out = net(sources, conditioning(["available"] * 3)) assert out.shape == (1, BANDS, size, size) assert torch.isfinite(out).all() @pytest.mark.parametrize("count", [1, 2, 7, 23]) def test_stack_depth_is_not_fixed(net, count): """Availability varies per slot, so the stack cannot be a compile-time constant.""" out = net(stack(count), conditioning(["available"] * count)) assert out.shape == (1, BANDS, 64, 64) # --------------------------------------------------------------- identity behaviour def expected_blend(sources, condition): """What an untrained model must output: the prior-weighted blend of its sources. Both heads are zero-initialised, so the polish gain is exactly one and the blend logits are exactly the prior.""" valid = condition[..., model.AVAILABLE] logits = model.blend_prior(condition).masked_fill(valid < 0.5, float("-inf")) weights = torch.nan_to_num(torch.softmax(logits, dim=1), nan=0.0) return (sources * weights[..., None, None, None]).sum(dim=1) def test_untrained_model_is_the_prior_weighted_blend(net): """The wiring gate: any scaling or orientation bug in the composition shows up here, and is invisible once training starts moving the loss. The stack is expanded through the trend builder first: derived candidates are inputs to the blend like any other, and their prior penalty is part of the wiring.""" frames = stack(5) condition = conditioning(["available"] * 5) out = net(frames, condition) expanded_frames, expanded_condition, _ = expand(net, frames, condition) assert torch.allclose(out, expected_blend(expanded_frames, expanded_condition), atol=1e-4) def test_identity_holds_when_fields_are_upsampled(net): """A 160-resolution field applied to larger sources must still be the identity.""" sources = torch.randn(1, 3, BANDS, 320, 320) condition = conditioning(["available"] * 3) out = net(sources, condition) assert torch.allclose(out, expected_blend(sources, condition), atol=1e-3) def test_missing_frames_take_no_weight(net): """A frame with no pixels must not contribute, however confident the head is.""" frames = stack(4) frames[:, 0] = 999.0 # would dominate any blend that included it condition = conditioning(["missing", "available", "available", "available"]) out = net(frames, condition) assert torch.allclose(out, expected_blend(frames, condition), atol=1e-4) assert out.abs().max() < 100, "the masked frame leaked into the blend" def test_the_targets_own_bands_win_only_where_it_holds_them(net): """The target slot is an entry at dt=0 holding its undamaged bands: the prior trusts it most where it has pixels, and it takes no weight in a lost band.""" frames = torch.stack([torch.full((BANDS, 32, 32), v) for v in (0.9, 0.2, 0.3)])[None] condition = conditioning(["available"] * 3, satellites=[True, True, True], dts=[0.0, -240.0, 240.0]) present = torch.ones(1, 3, BANDS) present[0, 0, 2] = 0.0 # the target lost band 2 out = net(frames, condition, present) assert torch.allclose(out, expected_band_blend(frames, condition, present), atol=1e-4) assert float(out[0, 2].detach().max()) < 0.31 # nothing of 0.9 in band 2 assert float(out[0, 0].detach().min()) > 0.5 # mostly the target's own def test_a_stack_with_nothing_usable_returns_zeros_not_nan(net): """The sampler declines such targets; the network itself must still not NaN.""" out = net(stack(4), conditioning(["missing"] * 4)) assert torch.isfinite(out).all() assert out.abs().max() == 0.0 def test_heads_start_at_zero(): """Training must start exactly at the hand-written policy; every step afterwards is a departure the data paid for.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2) for head in (fresh.weight, fresh.polish): with torch.no_grad(): assert float(head.weight.abs().sum()) == 0.0 assert float(head.bias.abs().sum()) == 0.0 # --------------------------------------------------------- structural non-collapse def test_output_is_a_bounded_combination_whatever_the_weights(): """The invariant that makes the old failure mode unreachable: with *arbitrary* parameters, the output of a single-frame stack stays within the polish bound of that frame. No parameter setting can emit an image that is not built from the inputs -- which is exactly what the collapsed run did.""" torch.manual_seed(3) wild = model.build(base=8, depth=2) with torch.no_grad(): for parameter in wild.parameters(): parameter.normal_(0.0, 2.0) frames = torch.full((1, 1, BANDS, 48, 48), 0.5) with torch.no_grad(): out = wild(frames, conditioning(["available"], dts=[240.0])) bound = 0.5 * float(np.exp(model.POLISH_RANGE)) lower = 0.5 * float(np.exp(-model.POLISH_RANGE)) assert float(out.max()) <= bound + 1e-4 assert float(out.min()) >= lower - 1e-4 def test_polish_gain_saturates_at_its_bound(): torch.manual_seed(0) fresh = model.build(base=8, depth=2).eval() with torch.no_grad(): fresh.polish.bias.fill_(1000.0) frames = torch.ones(1, 1, BANDS, 48, 48) out = fresh(frames, conditioning(["available"], dts=[240.0])) assert float(out.mean()) == pytest.approx(np.exp(model.POLISH_RANGE), rel=1e-3) # --------------------------------------------------------------------- agreement def test_agreement_flags_the_frame_that_disagrees(): """A frozen or torn frame announces itself as deviation from the stack median -- the deterministic replacement for both cross-attention and detector scores.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2) frames = torch.zeros(1, 4, BANDS, 32, 32) frames[:, 3] = 5.0 # the odd one out condition = conditioning(["available"] * 4) valid = condition[..., model.AVAILABLE] got = fresh._with_agreement(frames.reshape(4, BANDS, 32, 32), condition, valid, batch=1, stack=4) assert float(got[0, 3, model.AGREE_MEAN]) > float(got[0, 0, model.AGREE_MEAN]) + 0.5 assert float(got[0, 0, model.AGREE_MEAN]) < 0.1 # ------------------------------------------------------------------------- flow def test_flow_is_zero_for_a_simultaneous_frame(): """dt=0 means a real observation of the target instant: the flow allowance is zero there, however loud the head, so the most trusted candidate stays put.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2).eval() with torch.no_grad(): fresh.flow.bias.fill_(1000.0) frames = stack(1, size=48) out = fresh(frames, conditioning(["available"], dts=[0.0])) assert torch.allclose(out, frames[:, 0], atol=1e-4) def test_flow_displaces_by_the_dt_scaled_bound_at_saturation(): """A saturated flow head moves a ramp by exactly the allowance for its dt -- and no further. Displacement is a fraction of frame width, so the same model moves features the same physical distance at 640 and 1280.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2).eval() with torch.no_grad(): fresh.flow.bias.fill_(1000.0) # tanh saturates: full allowance, +x and +y size = 64 ramp = torch.linspace(-1.0, 1.0, size).view(1, 1, 1, size) frames = ramp.expand(1, 1, BANDS, size, size).clone() condition = conditioning(["available"], dts=[240.0]) out = fresh(frames, condition) allowance = model.MAX_FLOW_FRACTION * float(condition[0, 0, model.DT_LOG]) interior = out[..., 8:-8, 8:-8] - frames[:, 0, :, 8:-8, 8:-8] assert float(interior.mean()) == pytest.approx(2.0 * allowance, rel=0.05) def test_untrained_flow_is_the_identity(): frames = stack(3, size=32) warped = model._displace(frames[0], torch.zeros(3, 2, 32, 32)) assert torch.allclose(warped, frames[0], atol=1e-5) # ----------------------------------------------------------------------- trends def test_dt_encoding_round_trips_exactly(): """The trend builder recovers real seconds from the conditioning encoding.""" dts = [-72000.0, -960.0, -240.0, 0.0, 240.0, 61440.0] condition = conditioning(["available"] * len(dts), dts=dts) recovered = model.dt_from_conditioning(condition)[0] assert torch.allclose(recovered, torch.tensor(dts), rtol=1e-5, atol=1e-3) def trend_setup(dts, values, states=None): """A stack of uniform frames at given dts/values, expanded through the builder.""" count = len(dts) frames = torch.zeros(1, count, BANDS, 16, 16) for i, value in enumerate(values): frames[:, i] = value condition = conditioning(states or ["available"] * count, dts=dts) net = model.build(base=8, depth=2) return expand(net, frames, condition)[:2] def test_trend_candidates_recover_a_linear_ramp(): """Brightness rising linearly into the gap: the extrapolation must land exactly on the line's value at the target instant -- the reach a convex blend of the observations alone cannot have.""" expanded, condition = trend_setup( dts=[-480.0, -240.0, 240.0, 480.0], values=[0.2, 0.3, 0.5, 0.6]) past, future = expanded[0, -2], expanded[0, -1] assert torch.allclose(past, torch.full_like(past, 0.4), atol=1e-6) assert torch.allclose(future, torch.full_like(future, 0.4), atol=1e-6) assert bool(condition[0, -2, model.DERIVED]) and bool(condition[0, -1, model.DERIVED]) assert float(condition[0, -2, model.AVAILABLE]) == 1.0 # available, not missing def test_trend_reach_is_capped(): """A short-baseline slope far from the target must not extrapolate freely: at 6x its baseline the line would say 0.95, but reach caps at 4x -> 0.75.""" expanded, _ = trend_setup(dts=[-1680.0, -1440.0], values=[0.25, 0.35]) past = expanded[0, -2] assert torch.allclose(past, torch.full_like(past, 0.75), atol=1e-6) def test_trend_output_is_clamped_to_model_range(): expanded, _ = trend_setup(dts=[-480.0, -240.0], values=[0.2, 0.9]) assert float(expanded[0, -2].max()) <= 1.0 def test_trends_missing_when_a_side_lacks_two_frames(): expanded, condition = trend_setup(dts=[-240.0, 240.0, 480.0], values=[0.1, 0.2, 0.3]) assert float(condition[0, -2, model.MISSING]) == 1.0 # only one past frame assert float(condition[0, -1, model.AVAILABLE]) == 1.0 # future trend exists assert float(expanded[0, -2].abs().max()) == 0.0 def test_trends_ignore_invalid_and_cross_satellite_frames(): """Extrapolation slopes must come from clean target-satellite data only: a missing frame has no pixels, and a counterpart's residual gain error would be amplified by the lever arm.""" frames = torch.full((1, 4, BANDS, 16, 16), 0.5) condition = conditioning(["missing", "available", "available", "available"], satellites=[True, False, True, True], dts=[-480.0, -240.0, 240.0, 480.0]) net = model.build(base=8, depth=2) _, expanded, _ = net._trends(frames, condition) assert float(expanded[0, -2, model.MISSING]) == 1.0 # one missing, one cross-sat assert float(expanded[0, -1, model.AVAILABLE]) == 1.0 # two clean same-sat frames def test_agreement_is_zero_for_a_unanimous_stack(): torch.manual_seed(0) fresh = model.build(base=8, depth=2) frames = torch.full((1, 3, BANDS, 32, 32), 0.25) condition = conditioning(["available"] * 3) valid = condition[..., model.AVAILABLE] got = fresh._with_agreement(frames.reshape(3, BANDS, 32, 32), condition, valid, batch=1, stack=3) assert float(got[..., model.AGREE_MEAN:model.AGREE_MAX + 1].abs().max()) < 1e-6 def test_agreement_ignores_missing_frames(): """A zeroed missing frame must not drag the median toward black.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2) frames = torch.full((1, 3, BANDS, 32, 32), 0.5) frames[:, 0] = 0.0 # missing, zero-filled condition = conditioning(["missing", "available", "available"]) valid = condition[..., model.AVAILABLE] got = fresh._with_agreement(frames.reshape(3, BANDS, 32, 32), condition, valid, batch=1, stack=3) assert float(got[0, 1, model.AGREE_MEAN]) < 1e-6 assert float(got[0, 2, model.AGREE_MEAN]) < 1e-6 # ------------------------------------------------------------- per-band presence def expected_band_blend(sources, condition, present): """The untrained model's output when some frames lack some bands: per band, the prior blend over the valid frames that hold that band.""" valid = condition[..., model.AVAILABLE] logits = model.blend_prior(condition)[..., None].expand(-1, -1, BANDS) logits = logits.masked_fill((valid[..., None] < 0.5) | (present < 0.5), float("-inf")) weights = torch.nan_to_num(torch.softmax(logits, dim=1), nan=0.0) return (sources * weights[..., None, None]).sum(dim=1) def partial_setup(): """Three frames, none a trend source pair, the first missing bands 0 and 4.""" frames = torch.stack([torch.full((BANDS, 32, 32), v) for v in (0.2, 0.4, 0.6)])[None] condition = conditioning(["available"] * 3, satellites=[False, True, False], dts=[0.0, -240.0, 480.0]) present = torch.ones(1, 3, BANDS) present[0, 0, [0, 4]] = 0.0 return frames, condition, present def test_all_bands_present_is_the_default(net): """`present` of all ones is exactly what None means, bit for bit.""" frames = stack(5) condition = conditioning(["available", "available", "available", "missing", "available"]) assert torch.equal(net(frames, condition), net(frames, condition, torch.ones(1, 5, BANDS))) def test_a_frame_missing_a_band_still_contributes_its_other_bands(net): frames, condition, present = partial_setup() out = net(frames, condition, present) assert torch.allclose(out, expected_band_blend(frames, condition, present), atol=1e-4) without_first = expected_band_blend(frames, condition, torch.tensor([[[0.0] * BANDS, [1.0] * BANDS, [1.0] * BANDS]])) # Where the first frame holds the band, it pulls the blend toward its 0.2... assert float(out[0, 1].mean()) < float(without_first[0, 1].mean()) - 0.01 # ...and where it does not, the band is the blend of the other two alone. assert torch.allclose(out[0, 0], without_first[0, 0], atol=1e-4) assert torch.allclose(out[0, 4], without_first[0, 4], atol=1e-4) def test_a_missing_bands_pixels_never_reach_the_output(net): frames, condition, present = partial_setup() reference = net(frames, condition, present) frames[0, 0, [0, 4]] = 999.0 # whatever the stack carries there out = net(frames, condition, present) assert torch.allclose(out, reference, atol=1e-4) def test_a_band_no_frame_holds_comes_back_zero_not_nan(net): frames, condition, present = partial_setup() present[0, :, 2] = 0.0 out = net(frames, condition, present) assert torch.isfinite(out).all() assert float(out[0, 2].abs().max()) == 0.0 assert float(out[0, 3].abs().max()) > 0.1 def test_trends_come_only_from_frames_holding_every_band(): """A frame missing a band has no line to continue in it.""" frames = torch.full((1, 4, BANDS, 16, 16), 0.5) condition = conditioning(["available"] * 4, dts=[-480.0, -240.0, 240.0, 480.0]) present = torch.ones(1, 4, BANDS) present[0, 1, 3] = 0.0 # one of the past pair is partial net = model.build(base=8, depth=2) _, expanded, held = net._trends(frames, condition, present) assert float(expanded[0, -2, model.MISSING]) == 1.0 # past trend: missing assert float(expanded[0, -1, model.AVAILABLE]) == 1.0 # future trend: built assert held.shape == (1, 6, BANDS) and bool((held[0, 4:] == 1.0).all()) def test_agreement_ignores_the_bands_a_frame_lacks(): """A partial frame is judged on the bands it holds, not on whatever stands in for the rest.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2) frames = torch.full((1, 3, BANDS, 32, 32), 0.5) frames[0, 0, 0] = 0.9 # absent band, stand-in condition = conditioning(["available"] * 3) valid = condition[..., model.AVAILABLE] present = torch.ones(1, 3, BANDS) present[0, 0, 0] = 0.0 got = fresh._with_agreement(frames.reshape(3, BANDS, 32, 32), condition, valid, batch=1, stack=3, present=present) assert float(got[0, :, model.AGREE_MEAN:model.AGREE_MAX + 1].abs().max()) < 1e-6 def test_the_median_skips_frames_without_the_band(): torch.manual_seed(0) fresh = model.build(base=8, depth=2) frames = torch.full((1, 3, BANDS, 8, 8), 0.5) frames[0, 0, 2] = -1.0 valid = torch.ones(1, 3) present = torch.ones(1, 3, BANDS) present[0, 0, 2] = 0.0 median = fresh._stack_median(frames.reshape(3, BANDS, 8, 8), valid, 1, 3, present) assert float(median[0, 0, 2].min()) == pytest.approx(0.5) # ------------------------------------------------------------------------ training def test_gradients_reach_every_head(): """A head with no gradient path never learns, and zero-init hides it. Checked after three optimiser steps, because the zero initialisation is nested two deep: at step 1 only the output heads move, at step 2 the FiLM output layers behind them, and only then does gradient reach everything. Asserting earlier would fail on a model that is perfectly healthy.""" torch.manual_seed(0) fresh = model.build(base=8, depth=2) frames = stack(3, size=48) condition = conditioning(["available", "missing", "available"]) optimiser = torch.optim.Adam(fresh.parameters(), lr=1e-3) for _ in range(3): optimiser.zero_grad() out = fresh(frames, condition) out.square().mean().backward() optimiser.step() for name, parameter in fresh.named_parameters(): assert parameter.grad is not None, f"{name} has no gradient" assert parameter.grad.abs().sum() > 0, f"{name} gradient is identically zero" def smooth_stack(count, size=64, seed=1): """Spatially correlated frames; white noise tests nothing the real task needs.""" generator = torch.Generator().manual_seed(seed) coarse = torch.randn(1, count * BANDS, size // 8, size // 8, generator=generator) smooth = torch.nn.functional.interpolate(coarse, size=(size, size), mode="bicubic", align_corners=False) return smooth.view(1, count, BANDS, size, size) def train_a_little(net, frames, condition, target, steps=120, lr=3e-3): optimiser = torch.optim.Adam(net.parameters(), lr=lr) first = None for step in range(steps): optimiser.zero_grad() out = net(frames, condition) loss = (out - target).abs().mean() loss.backward() optimiser.step() if step == 0: first = loss.item() return first, loss.item() def test_model_learns_to_select_one_frame_from_the_stack(): """The weight head's core job: when one candidate is the answer, put the weight there. Fails in seconds if the softmax, masking or composition is miswired.""" torch.manual_seed(0) fresh = model.build(base=16, depth=2) frames = smooth_stack(3) first, last = train_a_little( fresh, frames, conditioning(["available"] * 3), frames[:, 1].clone() ) assert last < first * 0.2, f"loss barely moved: {first:.4f} -> {last:.4f}" def test_model_learns_a_per_band_gain_within_the_polish_bound(): """The polish head's core job: the residual drift the daily fit leaves behind.""" torch.manual_seed(0) fresh = model.build(base=16, depth=2) frames = smooth_stack(1) per_band = torch.tensor([1.2, 0.85, 1.1, 0.9, 1.25, 0.8]).view(1, BANDS, 1, 1) target = frames[:, 0] * per_band first, last = train_a_little(fresh, frames, conditioning(["available"], dts=[240.0]), target) assert last < first * 0.3, f"loss barely moved: {first:.4f} -> {last:.4f}" # ---------------------------------------------------------------- variant arms @pytest.mark.parametrize("flags", [ {"relative": True}, {"corr_flow": True}, {"attention": True}, {"relative": True, "corr_flow": True, "attention": True}, ]) def test_every_variant_is_the_prior_blend_untrained(flags): """Each screened cross-frame pathway must start silent: zero-init heads and gates mean an untrained variant is exactly the same sane policy as the baseline, and the v1-v3 collapse mode stays structurally unreachable.""" torch.manual_seed(0) net = model.build(base=8, depth=2, **flags).eval() frames = stack(5) condition = conditioning(["available"] * 5) out = net(frames, condition) expanded_frames, expanded_condition, _ = expand(net, frames, condition) assert torch.allclose(out, expected_blend(expanded_frames, expanded_condition), atol=1e-4) @pytest.mark.parametrize("flags", [ {"relative": True}, {"corr_flow": True}, {"attention": True}, ]) def test_variant_forward_and_backward_are_finite(flags): torch.manual_seed(1) net = model.build(base=8, depth=2, **flags) frames = stack(6, size=32) condition = conditioning(["available", "available", "available", "missing", "available", "available"]) out = net(frames, condition) assert torch.isfinite(out).all() out.square().mean().backward() for name, parameter in net.named_parameters(): if parameter.grad is not None: assert torch.isfinite(parameter.grad).all(), name def test_variant_checkpoints_round_trip_through_load_learned(tmp_path, monkeypatch): """A variant's flags ride in the checkpoint; loading must rebuild the same shapes or state_dict loading fails loudly.""" import torch as torch_module from suvi import fillers net = model.build(base=8, depth=2, relative=True, corr_flow=True) path = str(tmp_path / "variant.pt") torch_module.save({"model": net.state_dict(), "args": {"base": 8, "depth": 2, "cond_layout": list(model.COND_LAYOUT), "relative": True, "corr_flow": True, "offsets": [1, 4, 16, 64]}}, path) monkeypatch.setenv(fillers.LEARNED_CHECKPOINT_ENV, path) fillers._LEARNED.clear() loaded, _, _ = fillers.load_learned() assert loaded.relative and loaded.corr_flow and loaded.attend is None assert loaded.offsets == (1, 4, 16, 64) fillers._LEARNED.clear() @pytest.mark.parametrize("args", [ {"base": 8, "depth": 2, "cond_dim": 9}, # v8: a suspect column {"base": 8, "depth": 2, "cond_layout": ["available", "missing", "suspect"]}, ]) def test_a_checkpoint_under_another_conditioning_layout_is_refused( tmp_path, monkeypatch, args): """Its weights would read every column shifted: refuse by name, not by a shape error -- or worse, a v5 checkpoint whose shapes happen to fit.""" import torch as torch_module from suvi import fillers path = str(tmp_path / "old.pt") torch_module.save({"model": model.build(base=8, depth=2).state_dict(), "args": args}, path) monkeypatch.setenv(fillers.LEARNED_CHECKPOINT_ENV, path) fillers._LEARNED.clear() with pytest.raises(SystemExit, match="conditioning layout"): fillers.load_learned() fillers._LEARNED.clear() def test_correlation_peaks_at_the_true_shift(): """The cost volume must actually measure correspondence: a frame shifted by +2 px correlates best with the median at the matching displacement.""" torch.manual_seed(0) base_img = torch.randn(1, 6, 32, 32) shifted = torch.roll(base_img, shifts=2, dims=-1) volume = model._correlation(shifted, base_img, radius=3) centre = volume.shape[1] // 2 interior = volume[..., 8:-8, 8:-8].mean(dim=(-2, -1))[0] assert int(interior.argmax()) != centre best_dx = int(interior.argmax()) % 7 - 3 assert best_dx == -2 or best_dx == 2 # ------------------------------------------------- inference path memory refactor def _trained_looking(attention): """A model whose heads are non-zero, so the blend, gain and flow all act.""" torch.manual_seed(1) built = model.build(base=8, depth=2, attention=attention) with torch.no_grad(): for head in (built.weight, built.polish, built.flow): head.weight.normal_(0.0, 0.2) head.bias.normal_(0.0, 0.2) if built.attn_gate is not None: built.attn_gate.fill_(0.5) return built.eval() @pytest.mark.parametrize("attention", [True, False]) def test_band_at_a_time_inference_matches_the_batched_path_exactly( attention, monkeypatch): """Inference blends one band at a time to bound memory (a 24-candidate stack at 1280px peaked at 9.4 GB all at once); it must not change one bit. Both sides run without gradients: attention itself takes a different fused kernel when gradients are off, so a grad-vs-no-grad comparison would test torch.""" net_ = _trained_looking(attention) sources = stack(6, size=96, seed=3).clamp(-1, 1) cond = conditioning(["available", "available", "available", "missing", "available", "available"], satellites=[True, True, True, True, False, True], dts=[-240.0, 240.0, -960.0, 960.0, 0.0, -3840.0]) with torch.no_grad(): monkeypatch.setattr(model, "BAND_AT_A_TIME", False) batched = net_(sources, cond) monkeypatch.setattr(model, "BAND_AT_A_TIME", True) banded = net_(sources, cond) assert banded.shape == batched.shape == (1, BANDS, 96, 96) assert torch.equal(banded, batched) def test_inference_matches_the_gradient_path(monkeypatch): """Without gradients the encoder and the blend run a few frames at a time and the softmax is streamed; the same computation, so equal up to float rounding.""" net_ = _trained_looking(False) sources = stack(6, size=96, seed=4).clamp(-1, 1) cond = conditioning(["available", "available", "available", "missing", "available", "available"], satellites=[True, True, True, True, False, True], dts=[-240.0, 240.0, -960.0, 960.0, 0.0, -3840.0]) present = torch.ones(1, 6, BANDS) present[0, 4, 1] = 0.0 monkeypatch.setattr(model, "BAND_AT_A_TIME", False) with torch.enable_grad(): reference = net_(sources, cond, present).detach() with torch.no_grad(): assert torch.allclose(net_(sources, cond, present), reference, rtol=0, atol=5e-5) @pytest.mark.parametrize("band_at_a_time", [True, False]) def test_chunked_inference_handles_batches_ragged_chunks_and_unheld_bands( monkeypatch, band_at_a_time): """Two stacks of seven frames -- not a multiple of the chunk -- with one band no frame of the second stack holds: that band blends to zero, not NaN.""" monkeypatch.setattr(model, "BAND_AT_A_TIME", band_at_a_time) net_ = _trained_looking(False) sources = torch.cat([stack(7, size=48, seed=6), stack(7, size=48, seed=7)]).clamp(-1, 1) states = ["available", "available", "missing", "available", "available", "available", "available"] dts = [-240.0, 240.0, -960.0, 960.0, 0.0, -3840.0, 3840.0] cond = torch.cat([conditioning(states, dts=dts), conditioning(states, satellites=[True] * 6 + [False], dts=dts)]) present = torch.ones(2, 7, BANDS) present[1, :, 3] = 0.0 with torch.enable_grad(): reference = net_(sources, cond, present).detach() with torch.no_grad(): out = net_(sources, cond, present) assert torch.isfinite(out).all() assert float(out[1, 3].abs().max()) == 0.0 assert torch.allclose(out, reference, rtol=0, atol=5e-5) def test_trends_are_returned_apart_from_the_stack(): frames = torch.full((1, 4, BANDS, 16, 16), 0.5) condition = conditioning(["available"] * 4, dts=[-480.0, -240.0, 240.0, 480.0]) trends, expanded, present = model.build(base=8, depth=2)._trends(frames, condition) assert trends.shape == (1, 2, BANDS, 16, 16) assert expanded.shape[1] == present.shape[1] == 6 def test_a_one_frame_stack_has_no_trends_and_still_blends(net): frames = stack(1, size=48) trends, condition, _ = net._trends(frames, conditioning(["available"])) assert trends.shape[1] == 0 and condition.shape[1] == 1 out = net(frames, conditioning(["available"], dts=[240.0])) assert torch.isfinite(out).all() def test_training_keeps_the_batched_path_and_its_gradients(): net_ = _trained_looking(False).train() sources = stack(4, size=48, seed=5).clamp(-1, 1).requires_grad_(True) cond = conditioning(["available"] * 4) net_(sources, cond).square().mean().backward() assert sources.grad is not None and float(sources.grad.abs().sum()) > 0 assert net_.weight.weight.grad is not None