Files
larsupbandClaude Opus 5 7973203f38 test: run the whole suite without a ComfyUI installation
`pytest tests/` previously crashed during collection and 7 of 17 test files
were dead: 61 tests were reachable, all via ad-hoc standalone scripts. Now a
bare `pytest` collects everything and passes 238 tests with ComfyUI absent
(verified by running the suite from outside the ComfyUI tree, where
`import comfy` raises ModuleNotFoundError).

Import structure:
- Drop tests/__init__.py. With it, pytest walks up to the project root's
  __init__.py -- the ComfyUI node entry point -- and imports ComfyUI before
  any test runs.
- Import project code as `src.<module>` instead of putting src/ on sys.path
  and importing bare `merge.algorithms` / `validation` / `types`. Modules in
  src/ use package-relative imports (`from ..types import ...`) that cannot
  resolve when loaded top-level, and `types` collided with the stdlib module.
  Same change for the mock.patch targets in test_algorithms.
- Consolidate conftest.py in tests/, mocking comfy, folder_paths,
  comfy_extras and nodes. It stays in tests/ rather than the project root
  because pytest imports a root-level conftest as part of the root package,
  executing the ComfyUI entry point.
- Guard the script-style runners behind `if __name__ == "__main__":` so they
  no longer sys.exit() during collection. Those files still run standalone.
- Drop run_pytest.py: a mocking wrapper made redundant by conftest, unused
  and pointing at an unresolvable default path.

Bugs the dead tests were hiding:
- validators: the INCOMPATIBLE_DIMENSIONS check sat after the `continue` that
  skips the reference tensor, so a lone LoRA with mismatched up/down ranks
  passed validation unchecked. It is a per-LoRA check and now runs for every
  entry.
- decomposition: __init__ exported a QRDecomposer that exists nowhere, so
  `import src.decomposition` raised ImportError. Export and tests removed.

Stale expectations corrected:
- return_statistics is a constructor argument, not a decompose() kwarg.
- The zero-matrix rank guard only applies under dynamic rank selection; the
  test now exercises that path, plus a new case pinning fixed-rank behavior.
- `reconstruction_error < 0.5` for a rank-10 truncation of a random 100x50
  Gaussian is unreachable -- the optimum is 0.7557 and the decomposer hits
  0.7568. Assert near-optimality instead, and add a genuinely low-rank case
  that reconstructs to 0.003.
- sym/asym distributions differ only by float32 rounding (~5e-7), below the
  default atol of 1e-8.

RUN_TESTS.md is rewritten against the real setup: correct interpreter path,
the two test-file styles, the import rules for adding tests, and a per-file
coverage table. It no longer documents test_gradient_analyzer_integration.py,
which is not in the repo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 11:41:31 +02:00

333 lines
12 KiB
Python

"""
Unit tests for decomposition module.
Tests tensor decomposition functionality including SVD, QR, and error handling.
"""
import pytest
import torch
# Imported as `src.*` so the package-relative imports inside src/ resolve.
# conftest.py puts the project root on sys.path and mocks the ComfyUI modules.
from src.decomposition import (
SVDDecomposer,
RandomizedSVDDecomposer,
EnergyBasedRandomizedSVDDecomposer,
SingularValueDistribution,
)
class TestSVDDecomposer:
"""Tests for standard SVD decomposer."""
def test_basic_2d_decomposition(self):
"""Test basic 2D tensor decomposition."""
# return_statistics is a constructor option, not a decompose() argument.
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.randn(100, 50)
target_rank = 10
up, down, alpha, stats = decomposer.decompose(weight, target_rank)
# Check shapes
assert up.shape == (100, 10)
assert down.shape == (10, 50)
assert isinstance(alpha, float)
# Reconstruction quality is bounded by the discarded singular values, not
# by the decomposer: for a full-rank random Gaussian, a rank-10 truncation
# of 50 singular values necessarily loses ~75% of the Frobenius norm.
# Assert we land at that optimum rather than at an arbitrary threshold.
reconstructed = up @ down
reconstruction_error = torch.norm(weight - reconstructed) / torch.norm(weight)
S = torch.linalg.svdvals(weight)
optimal_error = (S[target_rank:].pow(2).sum() / S.pow(2).sum()).sqrt()
assert reconstruction_error < optimal_error + 0.01
def test_low_rank_input_reconstructs_accurately(self):
"""A genuinely low-rank matrix is recovered with little error."""
decomposer = SVDDecomposer()
weight = torch.randn(100, 10) @ torch.randn(10, 50)
weight = weight + 0.01 * torch.randn(100, 50)
up, down, alpha, _ = decomposer.decompose(weight, target_rank=10)
reconstruction_error = torch.norm(weight - up @ down) / torch.norm(weight)
assert reconstruction_error < 0.05
def test_4d_conv_decomposition(self):
"""Test 4D convolutional tensor decomposition."""
decomposer = SVDDecomposer()
weight = torch.randn(64, 32, 3, 3) # Conv layer
target_rank = 16
up, down, alpha, _ = decomposer.decompose(weight, target_rank)
# Check shapes
assert up.shape == (64, 16, 1, 1)
assert down.shape == (16, 32, 3, 3)
def test_symmetric_vs_asymmetric_distribution(self):
"""Test different singular value distributions."""
weight = torch.randn(50, 30)
target_rank = 10
# Symmetric distribution
decomposer_sym = SVDDecomposer(
distribution=SingularValueDistribution.SYMMETRIC
)
up_sym, down_sym, _, _ = decomposer_sym.decompose(weight, target_rank)
# Asymmetric distribution
decomposer_asym = SVDDecomposer(
distribution=SingularValueDistribution.ASYMMETRIC
)
up_asym, down_asym, _, _ = decomposer_asym.decompose(weight, target_rank)
# Both should reconstruct similarly but with different scaling
recon_sym = up_sym @ down_sym
recon_asym = up_asym @ down_asym
# The product is mathematically identical either way -- the distributions
# only decide whether S goes into up, into down, or is split as sqrt(S)
# across both. atol is needed because the default (1e-8) is below float32
# rounding for entries near zero; observed difference is ~5e-7.
assert torch.allclose(recon_sym, recon_asym, rtol=1e-4, atol=1e-5)
def test_statistics_calculation(self):
"""Test that statistics are calculated correctly."""
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.randn(80, 40)
target_rank = 20
_, _, _, stats = decomposer.decompose(weight, target_rank)
assert stats is not None
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
# Check statistics are reasonable
assert stats['new_rank'] == 20
assert 0.0 <= stats['sum_retained'] <= 1.0
assert 0.0 <= stats['fro_retained'] <= 1.0
def test_dynamic_rank_selection_ratio(self):
"""Test dynamic rank selection by singular value ratio."""
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.randn(100, 50)
_, _, _, stats = decomposer.decompose(
weight,
target_rank=50,
dynamic_method="sv_ratio",
dynamic_param=100.0 # Ratio threshold
)
# Rank should be selected based on ratio
assert stats['new_rank'] <= 50
assert stats['new_rank'] >= 1
def test_dynamic_rank_selection_cumulative(self):
"""Test dynamic rank selection by cumulative singular values."""
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.randn(100, 50)
_, _, _, stats = decomposer.decompose(
weight,
target_rank=50,
dynamic_method="sv_cumulative",
dynamic_param=0.95 # 95% of cumulative sum
)
# Rank should be selected to capture 95% of singular values
assert stats['sum_retained'] >= 0.90 # Allow some tolerance
def test_dynamic_rank_selection_frobenius(self):
"""Test dynamic rank selection by Frobenius norm."""
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.randn(100, 50)
_, _, _, stats = decomposer.decompose(
weight,
target_rank=50,
dynamic_method="sv_fro",
dynamic_param=0.99 # 99% of Frobenius norm
)
# Rank should be selected to retain 99% of Frobenius norm
assert stats['fro_retained'] >= 0.95 # Allow some tolerance
class TestRandomizedSVDDecomposer:
"""Tests for randomized SVD decomposer."""
def test_randomized_svd_approximation(self):
"""Test that randomized SVD produces good approximation."""
weight = torch.randn(200, 100)
target_rank = 20
# Standard SVD
decomposer_std = SVDDecomposer()
up_std, down_std, _, _ = decomposer_std.decompose(weight, target_rank)
recon_std = up_std @ down_std
# Randomized SVD
decomposer_rand = RandomizedSVDDecomposer(n_oversamples=10, n_iter=2)
up_rand, down_rand, _, _ = decomposer_rand.decompose(weight, target_rank)
recon_rand = up_rand @ down_rand
# Reconstructions should be similar
error_std = torch.norm(weight - recon_std)
error_rand = torch.norm(weight - recon_rand)
# Randomized should be close to standard (within 50% relative error)
assert abs(error_rand - error_std) / error_std < 0.5
def test_randomized_svd_small_matrix(self):
"""Test that small matrices fall back to standard SVD."""
decomposer = RandomizedSVDDecomposer()
weight = torch.randn(50, 30) # Small matrix
target_rank = 10
# Should not raise error
up, down, alpha, _ = decomposer.decompose(weight, target_rank)
assert up.shape == (50, 10)
assert down.shape == (10, 30)
class TestEnergyBasedRandomizedSVDDecomposer:
"""Tests for energy-based randomized SVD."""
def test_energy_based_rank_selection(self):
"""Test that energy threshold affects rank selection."""
weight = torch.randn(100, 50)
# Low energy threshold (fewer components)
decomposer_low = EnergyBasedRandomizedSVDDecomposer(
energy_threshold=0.8,
return_statistics=True
)
_, _, _, stats_low = decomposer_low.decompose(weight, target_rank=50)
# High energy threshold (more components)
decomposer_high = EnergyBasedRandomizedSVDDecomposer(
energy_threshold=0.99,
return_statistics=True
)
_, _, _, stats_high = decomposer_high.decompose(weight, target_rank=50)
# Higher threshold should generally use more components
# (though not guaranteed due to randomness)
assert stats_low is not None
assert stats_high is not None
class TestErrorHandling:
"""Tests for error handling in decomposition."""
def test_invalid_tensor_dimensions(self):
"""Test that invalid tensor dimensions raise errors."""
decomposer = SVDDecomposer()
weight = torch.randn(10) # 1D tensor (invalid)
with pytest.raises(ValueError, match="must be 2D, 3D, or 4D"):
decomposer.decompose(weight, target_rank=5)
def test_zero_matrix_handling(self):
"""Test handling of numerically zero matrices."""
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.zeros(50, 30)
# The zero-matrix guard lives in dynamic rank selection; without a
# dynamic_method the caller has pinned the rank and target_rank is honoured.
up, down, alpha, stats = decomposer.decompose(
weight, target_rank=10, dynamic_method="sv_ratio", dynamic_param=2.0
)
# Rank should collapse to the minimum for a numerically zero matrix
assert stats['new_rank'] == 1
def test_zero_matrix_fixed_rank_is_honoured(self):
"""A pinned rank (no dynamic_method) is kept even for a zero matrix."""
decomposer = SVDDecomposer(return_statistics=True)
weight = torch.zeros(50, 30)
up, down, alpha, stats = decomposer.decompose(weight, target_rank=10)
assert stats['new_rank'] == 10
assert torch.allclose(up @ down, torch.zeros(50, 30))
def test_invalid_dynamic_method(self):
"""Test that invalid dynamic method raises error."""
decomposer = SVDDecomposer()
weight = torch.randn(50, 30)
with pytest.raises(ValueError, match="Unknown dynamic method"):
decomposer.decompose(
weight,
target_rank=10,
dynamic_method="invalid_method"
)
# Fixtures
@pytest.fixture
def sample_2d_weight():
"""Fixture providing sample 2D weight tensor."""
return torch.randn(100, 50)
@pytest.fixture
def sample_4d_weight():
"""Fixture providing sample 4D convolutional weight."""
return torch.randn(64, 32, 3, 3)
class TestIntegrationWithFixtures:
"""Integration tests using fixtures."""
def test_all_decomposers_with_2d(self, sample_2d_weight):
"""Test all decomposers work with 2D tensors."""
decomposers = [
SVDDecomposer(),
RandomizedSVDDecomposer(),
EnergyBasedRandomizedSVDDecomposer(),
]
for decomposer in decomposers:
up, down, alpha, _ = decomposer.decompose(
sample_2d_weight,
target_rank=20
)
assert up.shape[0] == 100
assert up.shape[1] == 20
assert down.shape[0] == 20
assert down.shape[1] == 50
def test_all_decomposers_with_4d(self, sample_4d_weight):
"""Test all decomposers work with 4D conv tensors."""
decomposers = [
SVDDecomposer(),
RandomizedSVDDecomposer(),
]
for decomposer in decomposers:
up, down, alpha, _ = decomposer.decompose(
sample_4d_weight,
target_rank=16
)
assert up.shape == (64, 16, 1, 1)
assert down.shape == (16, 32, 3, 3)
if __name__ == "__main__":
pytest.main([__file__, "-v"])