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

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),
])