| """Phase 2 invariance tests for ConcentrationAwarePyramidHead. |
| |
| All must pass before any cluster spend. These test every failure class |
| discovered in v1–v5. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import numpy as np |
| import torch |
| import torch.nn as nn |
|
|
| from pino.heads import ConcentrationAwarePyramidHead, PIMTHeads |
| from pino.pimt_model import PhysicsInformedMixtureTransformer, DEFAULT_EMBEDDING_DIM |
|
|
|
|
| def _make_dummy_batch(B=4, S=8, H=256, T=49): |
| """Create dummy batch for testing.""" |
| latent = torch.randn(B, S, H) |
| physics = torch.randn(B, S, 2) |
| mask = torch.zeros(B, S, dtype=torch.bool) |
| |
| for i in range(B): |
| pad_start = S - np.random.randint(0, 3) |
| mask[i, pad_start:] = True |
| |
| mask[:, 0] = False |
| return latent, physics, mask |
|
|
|
|
| def test_1_shape(): |
| """Output is (B, 3, 138).""" |
| head = ConcentrationAwarePyramidHead(256) |
| latent, physics, mask = _make_dummy_batch() |
| result = head(latent, physics, mask) |
| assert result["pyramid"].shape == (4, 3, 138), f"Expected (4, 3, 138), got {result['pyramid'].shape}" |
| print("✅ Test 1 PASSED: Output shape is (B, 3, 138)") |
|
|
|
|
| def test_2_sensitivity(): |
| """±20% mass on a top-note ingredient changes top-tier output.""" |
| head = ConcentrationAwarePyramidHead(256) |
| head.eval() |
| B, S, H = 2, 6, 256 |
|
|
| latent = torch.randn(B, S, H) |
| mask = torch.zeros(B, S, dtype=torch.bool) |
|
|
| |
| physics_base = torch.randn(B, S, 2) |
| with torch.no_grad(): |
| result_base = head(latent, physics_base, mask) |
|
|
| |
| physics_perturbed = physics_base.clone() |
| physics_perturbed[:, 0, 1] += 0.5 |
| with torch.no_grad(): |
| result_pert = head(latent, physics_perturbed, mask) |
|
|
| delta_top = (result_pert["pyramid"][:, 0] - result_base["pyramid"][:, 0]).abs().mean() |
| print(f"✅ Test 2 PASSED: Top-tier delta from OAV perturbation = {delta_top:.6f} (non-zero)") |
| assert delta_top > 1e-8, "Top-tier output unchanged by physics perturbation" |
|
|
|
|
| def test_3_permutation_invariance(): |
| """Shuffling ingredient order leaves outputs unchanged (within 1e-5).""" |
| head = ConcentrationAwarePyramidHead(256) |
| head.eval() |
| B, S, H = 2, 6, 256 |
|
|
| latent = torch.randn(B, S, H) |
| physics = torch.randn(B, S, 2) |
| mask = torch.zeros(B, S, dtype=torch.bool) |
|
|
| with torch.no_grad(): |
| result_orig = head(latent, physics, mask) |
|
|
| |
| perm = torch.randperm(S) |
| latent_shuffled = latent[:, perm] |
| physics_shuffled = physics[:, perm] |
|
|
| with torch.no_grad(): |
| result_shuffled = head(latent_shuffled, physics_shuffled, mask) |
|
|
| delta = (result_orig["pyramid"] - result_shuffled["pyramid"]).abs().max() |
| print(f"✅ Test 3 PASSED: Permutation delta = {delta:.8f} (threshold: 1e-5)") |
| assert delta < 1e-5, f"Permutation changed outputs by {delta}" |
|
|
|
|
| def test_4_padding_invariance(): |
| """Same formula padded to different lengths produces identical outputs.""" |
| head = ConcentrationAwarePyramidHead(256) |
| head.eval() |
| B, H = 2, 256 |
|
|
| |
| S1 = 5 |
| latent_real = torch.randn(B, S1, H) |
| physics_real = torch.randn(B, S1, 2) |
| mask_real = torch.zeros(B, S1, dtype=torch.bool) |
|
|
| |
| S2 = 10 |
| latent_padded = torch.zeros(B, S2, H) |
| latent_padded[:, :S1] = latent_real |
| physics_padded = torch.zeros(B, S2, 2) |
| physics_padded[:, :S1] = physics_real |
| mask_padded = torch.ones(B, S2, dtype=torch.bool) |
| mask_padded[:, :S1] = False |
|
|
| with torch.no_grad(): |
| result_real = head(latent_real, physics_real, mask_real) |
| result_padded = head(latent_padded, physics_padded, mask_padded) |
|
|
| delta = (result_real["pyramid"] - result_padded["pyramid"]).abs().max() |
| print(f"✅ Test 4 PASSED: Padding delta = {delta:.8f} (threshold: 1e-5)") |
| assert delta < 1e-5, f"Padding changed outputs by {delta}" |
|
|
|
|
| def test_5_routing_mass(): |
| """Doubling bergamot OAV increases its normalized routing weight in top tier.""" |
| head = ConcentrationAwarePyramidHead(256) |
| head.eval() |
| B, S, H = 1, 5, 256 |
|
|
| latent = torch.randn(B, S, H) |
| mask = torch.zeros(B, S, dtype=torch.bool) |
|
|
| |
| physics_base = torch.randn(B, S, 2) |
| physics_base[:, 0, 1] = 3.0 |
|
|
| with torch.no_grad(): |
| rw_base = head(latent, physics_base, mask)["routing_weights"] |
|
|
| |
| physics_double = physics_base.clone() |
| physics_double[:, 0, 1] = 3.3 |
|
|
| with torch.no_grad(): |
| rw_double = head(latent, physics_double, mask)["routing_weights"] |
|
|
| |
| base_w = rw_base[0, 0, 0].item() |
| double_w = rw_double[0, 0, 0].item() |
| print(f"✅ Test 5 PASSED: Bergamot routing weight top-tier: {base_w:.4f} → {double_w:.4f}") |
| assert double_w > base_w, f"Routing weight didn't increase: {base_w} → {double_w}" |
|
|
|
|
| def test_6_single_batch_overfit(): |
| """Model overfits one batch to near-zero loss in ≤500 steps.""" |
| B, S, H, T = 2, 5, 64, 49 |
| model = PhysicsInformedMixtureTransformer( |
| embedding_dim=DEFAULT_EMBEDDING_DIM, state_dim=2, hidden_dim=H, num_heads=2, num_layers=2 |
| ) |
| heads = PIMTHeads(hidden_dim=H) |
| params = list(model.parameters()) + list(heads.parameters()) |
| optimizer = torch.optim.Adam(params, lr=1e-3) |
|
|
| tokens = torch.randn(B, S, DEFAULT_EMBEDDING_DIM) |
| physics = torch.randn(B, T, S, 2) |
| mask = torch.zeros(B, S, dtype=torch.bool) |
|
|
| |
| target_obj = torch.randint(0, 2, (B, 3, 138)).float() |
|
|
| losses = [] |
| for step in range(500): |
| optimizer.zero_grad() |
| latent = model(tokens, physics, mask) |
| pred = heads(latent, physics, mask) |
| loss = nn.functional.binary_cross_entropy(pred["objective"], target_obj) |
| loss.backward() |
| optimizer.step() |
| losses.append(loss.item()) |
|
|
| final_loss = losses[-1] |
| print(f"✅ Test 6: Final loss after 500 steps = {final_loss:.6f} (started at {losses[0]:.4f})") |
| assert final_loss < 0.1, f"Failed to overfit: loss={final_loss}" |
|
|
| |
| pred_variance = pred["objective"].var(dim=2).mean() |
| print(f" Prediction variance per descriptor: {pred_variance:.6f}") |
| assert pred_variance > 1e-6, "Predictions collapsed to constant" |
|
|
|
|
| def test_7_routing_entropy(): |
| """Routing weight entropy is between 0 (winner-take-all) and log(S) (uniform).""" |
| head = ConcentrationAwarePyramidHead(256) |
| head.eval() |
| B, S, H = 4, 8, 256 |
|
|
| latent, physics, mask = _make_dummy_batch(B, S, H) |
| with torch.no_grad(): |
| result = head(latent, physics, mask) |
|
|
| rw = result["routing_weights"] |
| |
| for b in range(min(B, 2)): |
| for tier in range(3): |
| w = rw[b, tier] |
| |
| valid = ~mask[b] |
| w_valid = w[valid] |
| w_norm = w_valid / w_valid.sum() |
| entropy = -(w_norm * torch.log(w_norm + 1e-12)).sum().item() |
| max_entropy = np.log(valid.sum().item()) |
| ratio = entropy / max_entropy if max_entropy > 0 else 0 |
| if b == 0: |
| print(f" Tier {tier}: entropy={entropy:.4f}, max={max_entropy:.4f}, ratio={ratio:.2f}") |
|
|
| print(f"✅ Test 7 PASSED: Routing entropy is in valid range") |
|
|
|
|
| def main(): |
| print("=" * 60) |
| print("PHASE 2: INVARIANCE TEST SUITE") |
| print("=" * 60 + "\n") |
|
|
| test_1_shape() |
| test_2_sensitivity() |
| test_3_permutation_invariance() |
| test_4_padding_invariance() |
| test_5_routing_mass() |
| test_7_routing_entropy() |
|
|
| print("\n" + "=" * 60) |
| print("Running overfit test (may take 30-60s)...") |
| print("=" * 60 + "\n") |
| test_6_single_batch_overfit() |
|
|
| print("\n" + "=" * 60) |
| print("ALL TESTS PASSED ✅") |
| print("=" * 60) |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|