`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>
254 lines
10 KiB
Python
254 lines
10 KiB
Python
from src.blocks import normalize_key, parse_weight_list
|
|
|
|
|
|
class TestNormalizeKey:
|
|
def test_string_key_unchanged(self):
|
|
assert normalize_key("diffusion_model.blocks.0.attn.wq.weight") == \
|
|
"diffusion_model.blocks.0.attn.wq.weight"
|
|
|
|
def test_tuple_key_uses_first_element(self):
|
|
assert normalize_key(("diffusion_model.single_blocks.0.linear1.weight", (0, 0, 9))) == \
|
|
"diffusion_model.single_blocks.0.linear1.weight"
|
|
|
|
|
|
class TestParseWeightList:
|
|
def test_simple_list(self):
|
|
assert parse_weight_list("1,1,0.8,0.5,0") == [1.0, 1.0, 0.8, 0.5, 0.0]
|
|
|
|
def test_whitespace_tolerated(self):
|
|
assert parse_weight_list(" 1.0 , 0.5 ") == [1.0, 0.5]
|
|
|
|
def test_empty_token_uses_default(self):
|
|
assert parse_weight_list("1,,0.5", default=1.0) == [1.0, 1.0, 0.5]
|
|
|
|
def test_empty_string_returns_single_default(self):
|
|
assert parse_weight_list("", default=1.0) == [1.0]
|
|
|
|
def test_invalid_token_uses_default(self):
|
|
assert parse_weight_list("1,abc,0.5", default=1.0) == [1.0, 1.0, 0.5]
|
|
|
|
def test_none_returns_empty(self):
|
|
assert parse_weight_list(None) == []
|
|
|
|
|
|
from src.blocks import make_category, key_weight
|
|
|
|
KREA2_DEF = {
|
|
"model": "KREA2",
|
|
"categories": [make_category("blocks", r"(?:^|\.)blocks\.(\d+)\.", 5, "1,1,1,0.8,0.5,0")],
|
|
"pathways": [
|
|
{"name": "txtfusion.layerwise", "regex": r"txtfusion\.layerwise_blocks\.", "weight": 0.3},
|
|
{"name": "txtfusion.refiner", "regex": r"txtfusion\.refiner_blocks\.", "weight": 0.7},
|
|
{"name": "txtmlp", "regex": r"(?:^|\.)txtmlp\.", "weight": 0.0},
|
|
],
|
|
}
|
|
|
|
|
|
class TestMakeCategory:
|
|
def test_structure(self):
|
|
cat = make_category("blocks", r"(?:^|\.)blocks\.(\d+)\.", 5, "1,0.5")
|
|
assert cat == {"name": "blocks", "regex": r"(?:^|\.)blocks\.(\d+)\.",
|
|
"group_size": 5, "group_weights": [1.0, 0.5], "default_weight": 1.0}
|
|
|
|
def test_group_size_clamped_to_at_least_one(self):
|
|
assert make_category("blocks", r"x", 0, "1")["group_size"] == 1
|
|
|
|
|
|
class TestKeyWeight:
|
|
def test_block_in_first_group(self):
|
|
# blocks 0-4 -> group 0 -> 1.0
|
|
assert key_weight("diffusion_model.blocks.3.attn.wq.weight", KREA2_DEF) == 1.0
|
|
|
|
def test_block_in_fourth_group(self):
|
|
# blocks 15-19 -> group 3 -> 0.8
|
|
assert key_weight("diffusion_model.blocks.17.mlp.up.weight", KREA2_DEF) == 0.8
|
|
|
|
def test_block_beyond_weight_list_uses_default(self):
|
|
# block 40 -> group 8, list has 6 entries -> default_weight 1.0
|
|
assert key_weight("diffusion_model.blocks.40.attn.wq.weight", KREA2_DEF) == 1.0
|
|
|
|
def test_category_regex_does_not_match_double_blocks(self):
|
|
# 'blocks' category must NOT catch 'double_blocks' (preceded by '_')
|
|
assert key_weight("diffusion_model.double_blocks.2.img_attn.qkv.weight", KREA2_DEF) == 1.0
|
|
|
|
def test_pathway_layerwise(self):
|
|
assert key_weight("diffusion_model.txtfusion.layerwise_blocks.1.attn.wq.weight", KREA2_DEF) == 0.3
|
|
|
|
def test_pathway_refiner(self):
|
|
assert key_weight("diffusion_model.txtfusion.refiner_blocks.0.mlp.up.weight", KREA2_DEF) == 0.7
|
|
|
|
def test_pathway_txtmlp(self):
|
|
assert key_weight("diffusion_model.txtmlp.0.weight", KREA2_DEF) == 0.0
|
|
|
|
def test_unmatched_key_is_one(self):
|
|
assert key_weight("diffusion_model.final_layer.weight", KREA2_DEF) == 1.0
|
|
|
|
|
|
from collections import OrderedDict
|
|
from src.blocks import compute_lora_weights, merge_selection, build_block_selection_dict, resolve_block_selection
|
|
|
|
|
|
class TestComputeLoraWeights:
|
|
def test_only_non_default_weights_stored(self):
|
|
keys = [
|
|
"diffusion_model.blocks.0.attn.wq.weight", # group 0 -> 1.0 (omitted)
|
|
"diffusion_model.blocks.25.attn.wq.weight", # group 5 -> 0.0 (stored)
|
|
"diffusion_model.txtmlp.0.weight", # pathway -> 0.0 (stored)
|
|
]
|
|
out = compute_lora_weights(keys, KREA2_DEF)
|
|
assert out == {
|
|
"diffusion_model.blocks.25.attn.wq.weight": 0.0,
|
|
"diffusion_model.txtmlp.0.weight": 0.0,
|
|
}
|
|
|
|
def test_tuple_keys_normalized(self):
|
|
keys = [("diffusion_model.blocks.25.attn.wq.weight", (0, 0, 9))]
|
|
out = compute_lora_weights(keys, KREA2_DEF)
|
|
assert out == {"diffusion_model.blocks.25.attn.wq.weight": 0.0}
|
|
|
|
|
|
class TestMergeSelection:
|
|
def test_adds_new_lora(self):
|
|
out = merge_selection({"a": {"k": 0.5}}, "b", {"k2": 0.2})
|
|
assert out == {"a": {"k": 0.5}, "b": {"k2": 0.2}}
|
|
|
|
def test_override_existing_lora(self):
|
|
out = merge_selection({"a": {"k": 0.5}}, "a", {"k2": 0.2})
|
|
assert out == {"a": {"k2": 0.2}}
|
|
|
|
def test_does_not_mutate_input(self):
|
|
base = {"a": {"k": 0.5}}
|
|
merge_selection(base, "b", {"k2": 0.2})
|
|
assert base == {"a": {"k": 0.5}}
|
|
|
|
|
|
class TestBuildBlockSelectionDict:
|
|
def test_first_node_returns_configs_with_none_chain(self):
|
|
result = build_block_selection_dict(None, index=0, definition=KREA2_DEF)
|
|
assert result == {"configs": {0: KREA2_DEF}, "chain": None}
|
|
|
|
def test_chaining_adds_second_index(self):
|
|
chain = {"configs": {0: KREA2_DEF}, "chain": None}
|
|
result = build_block_selection_dict(chain, index=1, definition=KREA2_DEF)
|
|
assert result == {"configs": {0: KREA2_DEF, 1: KREA2_DEF}, "chain": chain}
|
|
|
|
def test_chain_is_preserved(self):
|
|
chain = {"configs": {0: KREA2_DEF}, "chain": {"configs": {2: KREA2_DEF}, "chain": None}}
|
|
result = build_block_selection_dict(chain, index=1, definition=KREA2_DEF)
|
|
assert result["chain"] is chain
|
|
|
|
def test_index_collision_raises(self):
|
|
import pytest
|
|
chain = {"configs": {0: KREA2_DEF}, "chain": None}
|
|
with pytest.raises(ValueError, match="already has a config"):
|
|
build_block_selection_dict(chain, index=0, definition=KREA2_DEF)
|
|
|
|
def test_negative_index_raises(self):
|
|
import pytest
|
|
with pytest.raises(ValueError, match="negative"):
|
|
build_block_selection_dict(None, index=-1, definition=KREA2_DEF)
|
|
|
|
|
|
class TestResolveBlockSelection:
|
|
def _keys_by_name(self):
|
|
return OrderedDict([
|
|
("lora0", ["diffusion_model.blocks.25.attn.wq.weight"]),
|
|
("lora1", ["diffusion_model.blocks.17.attn.wq.weight"]),
|
|
])
|
|
|
|
def test_resolves_index_to_lora_name(self):
|
|
selection = {"configs": {0: KREA2_DEF}, "chain": None}
|
|
out = resolve_block_selection(selection, self._keys_by_name())
|
|
assert out == {"lora0": {"diffusion_model.blocks.25.attn.wq.weight": 0.0}}
|
|
|
|
def test_resolves_different_indices(self):
|
|
selection = {"configs": {0: KREA2_DEF, 1: KREA2_DEF}, "chain": None}
|
|
out = resolve_block_selection(selection, self._keys_by_name())
|
|
assert set(out.keys()) == {"lora0", "lora1"}
|
|
|
|
def test_out_of_range_index_skipped_with_warning(self, caplog):
|
|
selection = {"configs": {9: KREA2_DEF}, "chain": None}
|
|
out = resolve_block_selection(selection, dict(self._keys_by_name()))
|
|
assert out is None
|
|
assert "out of range" in caplog.text
|
|
|
|
def test_empty_configs_returns_none(self):
|
|
out = resolve_block_selection({"configs": {}, "chain": None}, self._keys_by_name())
|
|
assert out is None
|
|
|
|
def test_none_selection_returns_none(self):
|
|
out = resolve_block_selection(None, self._keys_by_name())
|
|
assert out is None
|
|
|
|
def test_empty_keys_by_name_returns_none(self):
|
|
out = resolve_block_selection({"configs": {0: KREA2_DEF}, "chain": None}, OrderedDict())
|
|
assert out is None
|
|
|
|
|
|
import torch
|
|
from src.blocks import apply_block_weights
|
|
|
|
|
|
class TestApplyBlockWeights:
|
|
def _uda(self):
|
|
up = torch.ones(4, 2)
|
|
down = torch.ones(2, 3)
|
|
return {"loraA": (up, down, torch.tensor(2.0))}
|
|
|
|
def test_no_selection_returns_input(self):
|
|
uda = self._uda()
|
|
assert apply_block_weights(uda, "k", None) is uda
|
|
|
|
def test_weight_scales_up_only(self):
|
|
out = apply_block_weights(self._uda(), "k", {"loraA": {"k": 0.5}})
|
|
up, down, alpha = out["loraA"]
|
|
assert torch.allclose(up, torch.full((4, 2), 0.5))
|
|
assert torch.allclose(down, torch.ones(2, 3)) # down untouched
|
|
assert float(alpha) == 2.0
|
|
|
|
def test_delta_scales_linearly(self):
|
|
base = self._uda()["loraA"]
|
|
base_delta = base[0] @ base[1]
|
|
up, down, _ = apply_block_weights(self._uda(), "k", {"loraA": {"k": 0.5}})["loraA"]
|
|
assert torch.allclose(up @ down, 0.5 * base_delta)
|
|
|
|
def test_weight_zero_drops_lora(self):
|
|
out = apply_block_weights(self._uda(), "k", {"loraA": {"k": 0.0}})
|
|
assert out == {}
|
|
|
|
def test_missing_key_defaults_to_one(self):
|
|
out = apply_block_weights(self._uda(), "other_key", {"loraA": {"k": 0.5}})
|
|
up, _, _ = out["loraA"]
|
|
assert torch.allclose(up, torch.ones(4, 2))
|
|
|
|
|
|
from src.blocks import build_krea2_definition, build_klein_definition
|
|
|
|
|
|
class TestBuildKrea2:
|
|
def test_shape_and_matching(self):
|
|
d = build_krea2_definition(blocks_group_size=5, blocks_weights="1,1,1,0.8,0.5,0",
|
|
txtfusion_layerwise=0.3, txtfusion_refiner=0.7, txtmlp=0.0)
|
|
assert d["model"] == "KREA2"
|
|
assert len(d["categories"]) == 1
|
|
assert {p["name"] for p in d["pathways"]} == {
|
|
"txtfusion.layerwise", "txtfusion.refiner", "txtmlp"}
|
|
# end-to-end sanity through key_weight
|
|
assert key_weight("diffusion_model.blocks.17.attn.wq.weight", d) == 0.8
|
|
assert key_weight("diffusion_model.txtfusion.refiner_blocks.0.mlp.up.weight", d) == 0.7
|
|
|
|
|
|
class TestBuildKlein:
|
|
def test_shape_and_matching(self):
|
|
d = build_klein_definition(double_blocks_group_size=1, double_blocks_weights="1,0.5",
|
|
single_blocks_group_size=5, single_blocks_weights="1,1,0")
|
|
assert d["model"] == "FLUX.2-Klein"
|
|
assert {c["name"] for c in d["categories"]} == {"double_blocks", "single_blocks"}
|
|
assert d["pathways"] == []
|
|
# double_blocks group_size 1 -> block 1 is group 1 -> 0.5
|
|
assert key_weight("diffusion_model.double_blocks.1.img_attn.qkv.weight", d) == 0.5
|
|
# single_blocks group_size 5 -> block 12 is group 2 -> 0.0
|
|
assert key_weight("diffusion_model.single_blocks.12.linear1.weight", d) == 0.0
|
|
# 'single_blocks' must not be matched by a 'double_blocks' regex and vice-versa
|
|
assert key_weight("diffusion_model.single_blocks.0.linear1.weight", d) == 1.0
|