`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>
75 lines
3.1 KiB
Python
75 lines
3.1 KiB
Python
import os, sys
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
import torch
|
|
from gta_helpers import load_gta, run
|
|
|
|
from mergekit.common import ModelReference, ModelPath, ImmutableMap
|
|
from mergekit.architecture import WeightInfo
|
|
from mergekit.io.tasks import GatherTensors
|
|
from mergekit.merge_methods import REGISTERED_MERGE_METHODS
|
|
from mergekit.merge_methods.generalized_task_arithmetic import GTATask
|
|
from mergekit.sparsify import RescaleNorm
|
|
|
|
gta = load_gta()
|
|
|
|
|
|
def _mk_gta(deltas, strengths, mode, normalize, density=1.0, rescale=None):
|
|
"""Run mergekit's GTATask on full deltas with a zeros base (LoRA convention)."""
|
|
tensors = {}
|
|
params = {}
|
|
base_ref = ModelReference(model=ModelPath(path="zeros.base"))
|
|
tensors[base_ref] = torch.zeros_like(deltas[0])
|
|
params[base_ref] = ImmutableMap({"weight": 0.0, "density": density})
|
|
for i, (d, s) in enumerate(zip(deltas, strengths)):
|
|
r = ModelReference(model=ModelPath(path=f"m.{i}"))
|
|
tensors[r] = d
|
|
params[r] = ImmutableMap({"weight": s, "density": density})
|
|
wi = WeightInfo(name="w", dtype=None, is_embed=False)
|
|
gt = GatherTensors(weight_info=ImmutableMap({r: WeightInfo(name=f"m{i}.w")
|
|
for i, r in enumerate(tensors)}))
|
|
method = REGISTERED_MERGE_METHODS[mode]
|
|
task = GTATask(method=method, tensors=gt, base_model=base_ref, weight_info=wi,
|
|
gather_tensors=gt, tensor_parameters=ImmutableMap(params),
|
|
int8_mask=False, normalize=normalize, lambda_=1.0,
|
|
rescale_norm=rescale)
|
|
return task.execute(tensors=tensors)
|
|
|
|
|
|
def _check(mode, mk_mode, normalize, density=1.0):
|
|
torch.manual_seed(0)
|
|
deltas = [torch.randn(6, 10), torch.randn(6, 10), torch.randn(6, 10)]
|
|
strengths = [1.0, 0.7, 0.4]
|
|
exp = _mk_gta(deltas, strengths, mk_mode, normalize, density)
|
|
got = gta.gta_merge(deltas, torch.tensor(strengths), mode=mode,
|
|
normalize=normalize, density=density)
|
|
assert torch.allclose(got, exp, atol=1e-5), (
|
|
f"{mode}/{mk_mode} normalize={normalize} density={density} "
|
|
f"maxdiff={ (got-exp).abs().max() }")
|
|
|
|
|
|
def test_task_arithmetic_parity():
|
|
_check("task_arithmetic", "task_arithmetic", normalize=True)
|
|
_check("task_arithmetic", "task_arithmetic", normalize=False)
|
|
|
|
|
|
def test_ties_parity():
|
|
_check("ties", "ties", normalize=True, density=0.6)
|
|
_check("ties", "ties", normalize=False, density=0.6)
|
|
|
|
|
|
def test_resolve_rescale_default_matches_mergekit():
|
|
assert gta.resolve_rescale_norm("della", "default") in (None, "l1")
|
|
assert gta.resolve_rescale_norm("ties", "none") is None
|
|
assert gta.resolve_rescale_norm("ties", "l2") == "l2"
|
|
|
|
|
|
# Runnable as a plain script (`python tests/<file>.py`); under pytest the
|
|
# test_* functions are collected directly, so the script runner must not fire
|
|
# at import time -- it calls sys.exit() and would abort collection.
|
|
if __name__ == "__main__":
|
|
run([
|
|
("task_arithmetic_parity", test_task_arithmetic_parity),
|
|
("ties_parity", test_ties_parity),
|
|
("resolve_rescale_default_matches_mergekit", test_resolve_rescale_default_matches_mergekit),
|
|
])
|