""" Comprehensive test suite for utility.py Tests cover: - Device and dtype mapping - Network dimension detection - Tensor dimension adjustment with SVD and QR - SVD-based LoRA rank resizing - QR-based LoRA rank resizing - Dynamic rank selection methods - Singular value indexing functions - Statistics calculation """ import sys import os import pytest import torch from typing import Tuple # Add the current directory to path for module imports sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from src.utility import ( map_device, find_network_dim, adjust_tensor_dims, perform_lora_svd, perform_lora_qr, resize_lora_rank, index_sv_cumulative, index_sv_fro, to_dtype, ) # Define UP_DOWN_ALPHA_TUPLE locally since we can't import from architectures UP_DOWN_ALPHA_TUPLE = Tuple[torch.Tensor, torch.Tensor, float] class TestMapDevice: """Tests for map_device function""" def test_map_device_with_strings(self): """Test device and dtype conversion from strings""" device, dtype = map_device("cpu", "float32") assert isinstance(device, torch.device) assert device.type == "cpu" assert dtype == torch.float32 def test_map_device_with_objects(self): """Test device and dtype when already torch objects""" input_device = torch.device("cpu") input_dtype = torch.float16 device, dtype = map_device(input_device, input_dtype) assert device == input_device assert dtype == input_dtype def test_map_device_mixed(self): """Test with mixed string and object inputs""" device, dtype = map_device(torch.device("cpu"), "bfloat16") assert isinstance(device, torch.device) assert dtype == torch.bfloat16 class TestFindNetworkDim: """Tests for find_network_dim function""" def test_find_network_dim_standard_lora(self): """Test finding network dimension from standard LoRA state dict""" lora_sd = { "layer1.lora_down.weight": torch.randn(8, 320), "layer1.lora_up.weight": torch.randn(320, 8), "layer2.lora_down.weight": torch.randn(8, 640), } dim = find_network_dim(lora_sd) assert dim == 8 def test_find_network_dim_different_ranks(self): """Test that it finds the first lora_down dimension""" lora_sd = { "layer1.lora_down.weight": torch.randn(16, 320), "layer2.lora_down.weight": torch.randn(8, 640), } dim = find_network_dim(lora_sd) assert dim in [16, 8] # Depends on dict ordering def test_find_network_dim_no_valid_keys(self): """Test when no lora_down keys exist""" lora_sd = { "layer1.lora_up.weight": torch.randn(320, 8), } dim = find_network_dim(lora_sd) assert dim is None def test_find_network_dim_non_2d_tensors(self): """Test with non-2D tensors (should be skipped)""" lora_sd = { "layer1.lora_down.weight": torch.randn(8, 320, 1, 1), # 4D tensor "layer2.lora_down.weight": torch.randn(16, 640), # Valid 2D } dim = find_network_dim(lora_sd) assert dim == 16 # Should find the 2D one class TestToDtype: """Tests for to_dtype function""" def test_to_dtype_float32(self): """Test conversion to float32""" assert to_dtype("float32") == torch.float32 def test_to_dtype_float16(self): """Test conversion to float16""" assert to_dtype("float16") == torch.float16 def test_to_dtype_bfloat16(self): """Test conversion to bfloat16""" assert to_dtype("bfloat16") == torch.bfloat16 def test_to_dtype_unknown_defaults_to_float32(self): """Test that unknown dtype defaults to float32""" assert to_dtype("unknown") == torch.float32 class TestPerformLoraSVD: """Tests for perform_lora_svd function""" def test_svd_basic_2d_tensor(self): """Test SVD on basic 2D weight matrix""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_svd(weight, target_rank=8) assert up.shape == (100, 8) assert down.shape == (8, 50) assert alpha == 8.0 # Verify reconstruction is close to original reconstructed = up @ down assert reconstructed.shape == weight.shape def test_svd_4d_conv_tensor(self): """Test SVD on 4D convolutional tensor""" weight = torch.randn(64, 32, 3, 3) up, down, alpha = perform_lora_svd(weight, target_rank=8) assert up.shape == (64, 8, 1, 1) assert down.shape == (8, 32, 3, 3) assert alpha == 8.0 def test_svd_symmetric_distribution(self): """Test SVD with symmetric singular value distribution""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_svd( weight, target_rank=8, distribute_singular_values=True ) # With symmetric distribution, both matrices should contain sqrt(S) # Verify the decomposition is valid reconstructed = up @ down assert reconstructed.shape == weight.shape def test_svd_asymmetric_distribution(self): """Test SVD with asymmetric singular value distribution""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_svd( weight, target_rank=8, distribute_singular_values=False ) # With asymmetric distribution, all S in up assert up.shape == (100, 8) assert down.shape == (8, 50) def test_svd_with_statistics(self): """Test SVD returns proper statistics""" weight = torch.randn(100, 50) up, down, alpha, stats = perform_lora_svd( weight, target_rank=8, return_statistics=True ) assert 'new_rank' in stats assert 'new_alpha' in stats assert 'sum_retained' in stats assert 'fro_retained' in stats assert 'max_ratio' in stats assert stats['new_rank'] == 8 assert stats['new_alpha'] == 8.0 assert 0.0 <= stats['sum_retained'] <= 1.0 assert 0.0 <= stats['fro_retained'] <= 1.0 def test_svd_dynamic_sv_ratio(self): """Test SVD with dynamic rank selection using sv_ratio""" weight = torch.randn(100, 50) up, down, alpha, stats = perform_lora_svd( weight, target_rank=32, dynamic_method='sv_ratio', dynamic_param=100.0, # Keep singular values > max_sv/100 return_statistics=True ) # Should select rank based on singular value ratio assert stats['new_rank'] <= 32 assert stats['new_rank'] >= 1 def test_svd_dynamic_sv_cumulative(self): """Test SVD with dynamic rank selection using sv_cumulative""" weight = torch.randn(100, 50) up, down, alpha, stats = perform_lora_svd( weight, target_rank=32, dynamic_method='sv_cumulative', dynamic_param=0.9, # Keep 90% of cumulative sum return_statistics=True ) assert stats['new_rank'] <= 32 assert stats['new_rank'] >= 1 # Should retain approximately 90% of singular values assert stats['sum_retained'] >= 0.75 # Some tolerance (looser due to random matrices) def test_svd_dynamic_sv_fro(self): """Test SVD with dynamic rank selection using sv_fro""" weight = torch.randn(100, 50) up, down, alpha, stats = perform_lora_svd( weight, target_rank=32, dynamic_method='sv_fro', dynamic_param=0.95, # Keep 95% of Frobenius norm return_statistics=True ) assert stats['new_rank'] <= 32 assert stats['new_rank'] >= 1 # Should retain approximately 95% of Frobenius norm assert stats['fro_retained'] >= 0.90 # Some tolerance def test_svd_scale_factor(self): """Test SVD with custom scale factor""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_svd( weight, target_rank=8, scale=2.0 ) assert alpha == 16.0 # 2.0 * 8 def test_svd_dtype_preservation(self): """Test that original dtype is preserved""" weight = torch.randn(100, 50, dtype=torch.float16) up, down, alpha = perform_lora_svd(weight, target_rank=8) assert up.dtype == torch.float16 assert down.dtype == torch.float16 def test_svd_rank_exceeds_dimensions(self): """Test SVD when target rank exceeds matrix dimensions""" weight = torch.randn(20, 30) up, down, alpha = perform_lora_svd(weight, target_rank=50) # Should be capped at min(20, 30) = 20 assert up.shape[1] == 20 assert down.shape[0] == 20 def test_svd_interrupt_handling(self): """Test that SVD completes without interruption""" # Note: The current implementation doesn't use throw_exception_if_processing_interrupted # This test verifies that SVD completes successfully weight = torch.randn(100, 50) up, down, alpha = perform_lora_svd(weight, target_rank=8) # Should complete successfully assert up.shape == (100, 8) assert down.shape == (8, 50) assert alpha == 8.0 class TestPerformLoraQR: """Tests for perform_lora_qr function""" def test_qr_basic_2d_tensor(self): """Test QR on basic 2D weight matrix""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_qr(weight, target_rank=8) assert up.shape == (100, 8) assert down.shape == (8, 50) assert alpha == 8.0 def test_qr_4d_conv_tensor(self): """Test QR on 4D convolutional tensor""" weight = torch.randn(64, 32, 3, 3) up, down, alpha = perform_lora_qr(weight, target_rank=8) assert up.shape == (64, 8, 1, 1) assert down.shape == (8, 32, 3, 3) assert alpha == 8.0 def test_qr_symmetric_distribution(self): """Test QR with symmetric distribution""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_qr( weight, target_rank=8, distribute_singular_values=True ) assert up.shape == (100, 8) assert down.shape == (8, 50) def test_qr_asymmetric_distribution(self): """Test QR with asymmetric distribution""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_qr( weight, target_rank=8, distribute_singular_values=False ) assert up.shape == (100, 8) assert down.shape == (8, 50) def test_qr_with_statistics(self): """Test QR returns proper statistics""" weight = torch.randn(100, 50) up, down, alpha, stats = perform_lora_qr( weight, target_rank=8, return_statistics=True ) assert 'new_rank' in stats assert 'new_alpha' in stats assert 'sum_retained' in stats assert 'fro_retained' in stats assert 'max_ratio' in stats assert stats['new_rank'] == 8 assert stats['new_alpha'] == 8.0 assert stats['sum_retained'] == 1.0 # Not available for QR assert stats['max_ratio'] == 1.0 # Not available for QR def test_qr_scale_factor(self): """Test QR with custom scale factor""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_qr( weight, target_rank=8, scale=2.0 ) assert alpha == 16.0 # 2.0 * 8 def test_qr_faster_than_svd(self): """Test that QR is typically faster than SVD (timing test)""" import time weight = torch.randn(500, 500) start = time.time() perform_lora_qr(weight, target_rank=32) qr_time = time.time() - start start = time.time() perform_lora_svd(weight, target_rank=32) svd_time = time.time() - start # QR should generally be faster, but we'll just verify both complete assert qr_time > 0 assert svd_time > 0 def test_qr_interrupt_handling(self): """Test that QR completes without interruption""" # Note: The current implementation doesn't use throw_exception_if_processing_interrupted # This test verifies that QR completes successfully weight = torch.randn(100, 50) up, down, alpha = perform_lora_qr(weight, target_rank=8) # Should complete successfully assert up.shape == (100, 8) assert down.shape == (8, 50) assert alpha == 8.0 class TestResizeLoraRank: """Tests for resize_lora_rank function""" def test_resize_lora_rank_basic(self): """Test basic LoRA rank resizing""" down = torch.randn(16, 320) up = torch.randn(640, 16) down_new, up_new = resize_lora_rank(down, up, new_dim=8) assert down_new.shape == (8, 320) assert up_new.shape == (640, 8) def test_resize_lora_rank_conv_layers(self): """Test LoRA rank resizing for convolutional layers""" down = torch.randn(16, 320, 1, 1) up = torch.randn(640, 16, 1, 1) down_new, up_new = resize_lora_rank(down, up, new_dim=8) assert down_new.shape == (8, 320, 1, 1) assert up_new.shape == (640, 8, 1, 1) def test_resize_lora_rank_upscaling(self): """Test resizing to larger rank (should pad with zeros)""" down = torch.randn(8, 320) up = torch.randn(640, 8) down_new, up_new = resize_lora_rank(down, up, new_dim=16) assert down_new.shape == (16, 320) assert up_new.shape == (640, 16) def test_resize_lora_rank_preserves_approximation(self): """Test that resizing preserves weight approximation quality""" down = torch.randn(16, 100) up = torch.randn(200, 16) # Original reconstruction original = up @ down down_new, up_new = resize_lora_rank(down, up, new_dim=16) # New reconstruction should be similar reconstructed = up_new @ down_new # Should be very close since we're keeping same rank assert torch.allclose(original, reconstructed, rtol=1e-3, atol=1e-5) def test_resize_lora_rank_incompatible_shapes(self): """Test error handling for incompatible tensor shapes""" down = torch.randn(16, 320) up = torch.randn(640, 8) # Incompatible with down with pytest.raises(RuntimeError, match="Failed to compute full LoRA matrix"): resize_lora_rank(down, up, new_dim=8) class TestAdjustTensorDims: """Tests for adjust_tensor_dims function""" def test_adjust_tensor_dims_matching_shapes(self): """Test when all tensors already have matching dimensions""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora3": (torch.randn(640, 8), torch.randn(8, 320), 8.0), } result = adjust_tensor_dims(ups_downs_alphas, apply_svd=False) assert len(result) == 3 for lora_name, (up, down, alpha) in result.items(): assert up.shape == (640, 8) assert down.shape == (8, 320) def test_adjust_tensor_dims_mismatched_without_svd(self): """Test error when dimensions don't match and SVD is disabled""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 16), torch.randn(16, 320), 16.0), } with pytest.raises(ValueError, match="LoRA up tensors have different shapes"): adjust_tensor_dims(ups_downs_alphas, apply_svd=False) def test_adjust_tensor_dims_with_svd(self): """Test dimension adjustment using SVD""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 16), torch.randn(16, 320), 16.0), "lora3": (torch.randn(640, 12), torch.randn(12, 320), 12.0), } result = adjust_tensor_dims(ups_downs_alphas, apply_svd=True, svd_rank=-1) # All should be resized to rank of first tensor (8) assert len(result) == 3 for lora_name, (up, down, alpha) in result.items(): assert up.shape == (640, 8) assert down.shape == (8, 320) def test_adjust_tensor_dims_with_qr(self): """Test dimension adjustment using QR decomposition""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 16), torch.randn(16, 320), 16.0), } result = adjust_tensor_dims(ups_downs_alphas, apply_svd=True, svd_rank=-1, method='qr') # All should be resized to rank 8 assert len(result) == 2 for lora_name, (up, down, alpha) in result.items(): assert up.shape == (640, 8) assert down.shape == (8, 320) def test_adjust_tensor_dims_custom_target_rank(self): """Test dimension adjustment to custom target rank""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 16), torch.randn(16, 320), 16.0), } result = adjust_tensor_dims(ups_downs_alphas, apply_svd=True, svd_rank=4) # All should be resized to rank 4 for lora_name, (up, down, alpha) in result.items(): assert up.shape == (640, 4) assert down.shape == (4, 320) def test_adjust_tensor_dims_preserves_dtype(self): """Test that dtype is preserved during adjustment""" ups_downs_alphas = { "lora1": (torch.randn(640, 8, dtype=torch.float16), torch.randn(8, 320, dtype=torch.float16), 8.0), "lora2": (torch.randn(640, 16, dtype=torch.float16), torch.randn(16, 320, dtype=torch.float16), 16.0), } result = adjust_tensor_dims(ups_downs_alphas, apply_svd=True, svd_rank=8) for lora_name, (up, down, alpha) in result.items(): assert up.dtype == torch.float16 assert down.dtype == torch.float16 class TestIndexSvCumulative: """Tests for index_sv_cumulative function""" def test_index_sv_cumulative_basic(self): """Test basic cumulative singular value indexing""" S = torch.tensor([10.0, 5.0, 3.0, 2.0, 1.0, 0.5, 0.1]) # Target 90% of cumulative sum index = index_sv_cumulative(S, 0.9) # Should select enough values to reach 90% assert index >= 1 assert index <= len(S) def test_index_sv_cumulative_low_threshold(self): """Test with low threshold (should select fewer values)""" S = torch.tensor([10.0, 5.0, 3.0, 2.0, 1.0]) index = index_sv_cumulative(S, 0.5) # 50% threshold should select fewer values assert index <= len(S) // 2 + 1 def test_index_sv_cumulative_high_threshold(self): """Test with high threshold (should select more values)""" S = torch.tensor([10.0, 5.0, 3.0, 2.0, 1.0]) index = index_sv_cumulative(S, 0.99) # 99% threshold should select most values assert index >= 3 def test_index_sv_cumulative_boundary_clamping(self): """Test that index is clamped to valid range""" S = torch.tensor([10.0, 5.0, 3.0]) # Even with 100%, should not exceed len(S) - 1 index = index_sv_cumulative(S, 1.0) assert index >= 1 assert index <= len(S) class TestIndexSvFro: """Tests for index_sv_fro function""" def test_index_sv_fro_basic(self): """Test basic Frobenius norm indexing""" S = torch.tensor([10.0, 5.0, 3.0, 2.0, 1.0, 0.5, 0.1]) # Target 90% of Frobenius norm index = index_sv_fro(S, 0.9) assert index >= 1 assert index <= len(S) def test_index_sv_fro_vs_cumulative(self): """Test that Frobenius indexing differs from cumulative""" S = torch.tensor([10.0, 5.0, 3.0, 2.0, 1.0]) index_fro = index_sv_fro(S, 0.9) index_cum = index_sv_cumulative(S, 0.9) # Due to squaring, Frobenius should generally select fewer values # for same threshold (large values dominate more) assert index_fro >= 1 assert index_cum >= 1 def test_index_sv_fro_boundary_clamping(self): """Test that index is clamped to valid range""" S = torch.tensor([10.0, 5.0, 3.0]) index = index_sv_fro(S, 0.99) assert index >= 1 assert index <= len(S) class TestEdgeCases: """Tests for edge cases and error conditions""" def test_svd_zero_matrix(self): """Test SVD on zero matrix""" weight = torch.zeros(100, 50) up, down, alpha = perform_lora_svd(weight, target_rank=8) # Should handle gracefully assert up.shape == (100, 8) assert down.shape == (8, 50) def test_svd_very_small_values(self): """Test SVD with very small weight values""" weight = torch.randn(100, 50) * 1e-8 up, down, alpha = perform_lora_svd(weight, target_rank=8) assert up.shape == (100, 8) assert down.shape == (8, 50) def test_svd_single_rank(self): """Test SVD with rank 1""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_svd(weight, target_rank=1) assert up.shape == (100, 1) assert down.shape == (1, 50) assert alpha == 1.0 def test_qr_single_rank(self): """Test QR with rank 1""" weight = torch.randn(100, 50) up, down, alpha = perform_lora_qr(weight, target_rank=1) assert up.shape == (100, 1) assert down.shape == (1, 50) assert alpha == 1.0 def test_empty_ups_downs_alphas_dict(self): """Test adjust_tensor_dims with empty dict""" ups_downs_alphas = {} with pytest.raises(StopIteration): # Should raise StopIteration when calling next() on empty dict adjust_tensor_dims(ups_downs_alphas) def test_single_lora_adjustment(self): """Test adjust_tensor_dims with single LoRA (no adjustment needed)""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), } result = adjust_tensor_dims(ups_downs_alphas, apply_svd=False) assert len(result) == 1 up, down, alpha = result["lora1"] assert up.shape == (640, 8) assert down.shape == (8, 320) class TestIntegration: """Integration tests combining multiple functions""" def test_full_pipeline_svd(self): """Test full pipeline: adjust dims -> SVD resize""" # Create LoRAs with different ranks ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 16), torch.randn(16, 320), 16.0), "lora3": (torch.randn(640, 12), torch.randn(12, 320), 12.0), } # Adjust to common rank adjusted = adjust_tensor_dims(ups_downs_alphas, apply_svd=True, svd_rank=8) # Verify all have same rank for lora_name, (up, down, alpha) in adjusted.items(): assert up.shape == (640, 8) assert down.shape == (8, 320) def test_full_pipeline_qr(self): """Test full pipeline with QR method""" ups_downs_alphas = { "lora1": (torch.randn(640, 8), torch.randn(8, 320), 8.0), "lora2": (torch.randn(640, 16), torch.randn(16, 320), 16.0), } # Adjust using QR adjusted = adjust_tensor_dims(ups_downs_alphas, apply_svd=True, svd_rank=8, method='qr') for lora_name, (up, down, alpha) in adjusted.items(): assert up.shape == (640, 8) assert down.shape == (8, 320) def test_device_dtype_pipeline(self): """Test device and dtype handling through pipeline""" device, dtype = map_device("cpu", "float16") weight = torch.randn(100, 50, dtype=dtype) up, down, alpha = perform_lora_svd( weight, target_rank=8, device=device.type, dtype=dtype ) assert up.dtype == dtype assert down.dtype == dtype assert up.device.type == "cpu" assert down.device.type == "cpu" if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"])