Closes the >= 2-LoRA e2e gap left after Task 5 (single-active-LoRA was already covered by TestSingleLoraVirtualPath): two rank-4 adapters on the same 16x16 layer key at loader strengths 1.0 / 0.7, captured off a fake ModelPatcher chain and driven through execute_inline into the REAL optimize_merge (Pass 1 conflict analysis + Pass 2 group merge on virtual items). - Numeric golden: with a weighted_sum override via an advanced Settings dict (the node's supported surface for engine kwargs — execute_inline deliberately forwards no loose kwargs), the applied patch is verified exactly equal to 1.0*(upA@downA) + 0.7*(upB@downB); also asserts the fingerprint report, add_patches on a clone (returned model IS that clone), and the untouched original chain entries on the input model. - Conflict e2e: deliberately near-antiparallel up/down matrices exercise the per_prefix conflict-analysis path; asserts completion, finite nonzero merged patch, fingerprints. - Test-only refactor: extracted TestSingleLoraVirtualPath's fake-model threading into a shared _pipeline_model helper and fixed its clone fidelity (clones now copy the CURRENT patch lists, like the real ModelPatcher.clone, so a clone of the stripped clone stays stripped). - _adapter now accepts explicit up/down matrices for deterministic diffs. No engine changes were needed — the virtual-item pipeline handled the multi-LoRA path as-is. Determinism knobs (cache_patches/patch_compression disabled, CPU SVD) ride through the existing settings delegation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
977 lines
46 KiB
Python
977 lines
46 KiB
Python
"""Tests for the inline chain-filter optimizer node."""
|
||
import struct
|
||
import types
|
||
import unittest
|
||
import uuid
|
||
from unittest import mock
|
||
|
||
import torch
|
||
|
||
# Reuse the stub installer / module instance from the main test module.
|
||
from tests.test_lora_optimizer import lora_optimizer
|
||
|
||
|
||
def _adapter(rank=4, out_dim=8, in_dim=8, up=None, down=None):
|
||
"""Minimal LoRAAdapter-like payload the engine can expand. Explicit
|
||
up/down matrices make the expanded diff deterministic (alpha == rank, so
|
||
the diff is exactly up @ down)."""
|
||
if up is None:
|
||
up = torch.randn(out_dim, rank)
|
||
if down is None:
|
||
down = torch.randn(rank, in_dim)
|
||
return lora_optimizer.LoRAAdapter(
|
||
loaded_keys=set(),
|
||
weights=(up, down, float(rank), None, None, None),
|
||
)
|
||
|
||
|
||
def _entry(strength, payload, strength_model=1.0, offset=None, function=None):
|
||
"""A ModelPatcher patch-list entry, shaped like model_patcher.py:807."""
|
||
return (strength, payload, strength_model, offset, function)
|
||
|
||
|
||
class TestPatchClassification(unittest.TestCase):
|
||
def test_adapter_is_capturable(self):
|
||
e = _entry(0.8, _adapter())
|
||
self.assertTrue(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_diff_tuple_is_capturable(self):
|
||
e = _entry(1.0, ("diff", (torch.randn(4, 4),)))
|
||
self.assertTrue(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_set_tuple_passes_through(self):
|
||
e = _entry(1.0, ("set", (torch.randn(4, 4),)))
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_nonunit_strength_model_passes_through(self):
|
||
e = _entry(1.0, _adapter(), strength_model=0.5)
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_function_entry_passes_through(self):
|
||
e = _entry(1.0, _adapter(), function=lambda w: w)
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_unknown_object_passes_through(self):
|
||
e = _entry(1.0, object())
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_non_5_tuple_entry_passes_through(self):
|
||
# older/nonstandard third-party nodes may store short entries — the
|
||
# classifier must not crash on unpack, just pass them through
|
||
e = (1.0, _adapter(), 1.0)
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_padded_diff_passes_through(self):
|
||
# comfy pads the BASE weight at apply time for this shape — we cannot
|
||
# faithfully expand it to a bare diff tensor
|
||
e = _entry(1.0, ("diff", (torch.randn(4, 4), {"pad_weight": True})))
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
def test_malformed_diff_passes_through(self):
|
||
e = _entry(1.0, ("diff", None))
|
||
self.assertFalse(lora_optimizer._LoRAMergeBase._is_capturable_entry(e))
|
||
|
||
|
||
def _chain_patches(*loras):
|
||
"""Simulate a loader chain: each lora is {key: payload} applied at a
|
||
strength. Distinct float objects per call, entries appended in order —
|
||
exactly what ModelPatcher.add_patches does."""
|
||
patches = {}
|
||
seen_ids = set()
|
||
for strength_value, lora in loras:
|
||
# struct round-trip mints a FRESH float object even for values whose
|
||
# literals CPython folds/interns (e.g. 1.0 used twice in one test).
|
||
# float(x) would return the SAME object for an exact float input.
|
||
s = struct.unpack("d", struct.pack("d", strength_value))[0]
|
||
assert id(s) not in seen_ids, "float object reused across simulated calls"
|
||
seen_ids.add(id(s))
|
||
for key, payload in lora.items():
|
||
patches.setdefault(key, []).append(_entry(s, payload))
|
||
return patches
|
||
|
||
|
||
class TestChainGroupReconstruction(unittest.TestCase):
|
||
def _reconstruct(self, patches):
|
||
return lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(patches)
|
||
|
||
def test_single_lora(self):
|
||
patches = _chain_patches((0.8, {"a": _adapter(), "b": _adapter()}))
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 1)
|
||
self.assertAlmostEqual(groups[0]["strength"], 0.8)
|
||
self.assertEqual(set(groups[0]["entries"]), {"a", "b"})
|
||
|
||
def test_two_loras_chain_order(self):
|
||
patches = _chain_patches(
|
||
(0.8, {"a": _adapter(), "b": _adapter()}),
|
||
(0.5, {"a": _adapter(), "c": _adapter()}),
|
||
)
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 2)
|
||
self.assertAlmostEqual(groups[0]["strength"], 0.8)
|
||
self.assertAlmostEqual(groups[1]["strength"], 0.5)
|
||
self.assertEqual(set(groups[1]["entries"]), {"a", "c"})
|
||
|
||
def test_subset_alignment(self):
|
||
# A patches attn only; B patches attn+mlp. Naive index-as-identity
|
||
# would misattribute B's mlp entry (position 0 there) to A.
|
||
patches = _chain_patches(
|
||
(0.8, {"attn": _adapter()}),
|
||
(0.5, {"attn": _adapter(), "mlp": _adapter()}),
|
||
)
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 2)
|
||
self.assertEqual(set(groups[0]["entries"]), {"attn"})
|
||
self.assertEqual(set(groups[1]["entries"]), {"attn", "mlp"})
|
||
|
||
def test_same_strength_value_distinct_objects(self):
|
||
patches = _chain_patches(
|
||
(1.0, {"a": _adapter()}),
|
||
(1.0, {"a": _adapter(), "b": _adapter()}),
|
||
)
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 2)
|
||
|
||
def test_shared_float_object_fallback(self):
|
||
# Pathological: two calls share ONE float object (interning). The
|
||
# id-group then holds two entries on key "a" — must split by per-key
|
||
# order instead of returning a corrupt group.
|
||
s = 1.0
|
||
patches = {}
|
||
for lora in ({"a": _adapter()}, {"a": _adapter(), "b": _adapter()}):
|
||
for key, payload in lora.items():
|
||
patches.setdefault(key, []).append(_entry(s, payload))
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 2)
|
||
for g in groups:
|
||
keys = [k for k in g["entries"]]
|
||
self.assertEqual(len(keys), len(set(keys)))
|
||
|
||
def test_offset_entries_get_tuple_keys(self):
|
||
off = (0, 0, 4)
|
||
patches = {"qkv": [_entry(0.7, _adapter(), offset=off)]}
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(list(groups[0]["entries"]), [("qkv", off)])
|
||
|
||
def test_noncapturable_entries_ignored(self):
|
||
patches = _chain_patches((0.8, {"a": _adapter()}))
|
||
patches["a"].append(_entry(1.0, ("set", (torch.zeros(2, 2),))))
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 1)
|
||
self.assertEqual(set(groups[0]["entries"]), {"a"})
|
||
|
||
def test_empty_patches(self):
|
||
self.assertEqual(self._reconstruct({}), [])
|
||
|
||
def test_non_5_tuple_entry_ignored_without_raising(self):
|
||
patches = _chain_patches((0.8, {"a": _adapter()}))
|
||
patches["a"].append((1.0, _adapter(), 1.0)) # nonstandard 3-tuple
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 1)
|
||
self.assertEqual(set(groups[0]["entries"]), {"a"})
|
||
|
||
def test_interleaved_collision_no_fragmentation(self):
|
||
# Distinct-strength loader X on {a} first, then two interned-strength
|
||
# loaders B and C both patching {a, b}. The collision sub-gid must be
|
||
# the per-target-key collision ORDINAL — splitting on the absolute
|
||
# per-key position fragments this into 4 groups [a],[a,b],[a],[b]
|
||
# because X shifts the positions on "a" but not on "b".
|
||
d = struct.unpack("d", struct.pack("d", 0.6))[0]
|
||
s = 1.0
|
||
pX = _adapter()
|
||
pB_a, pB_b = _adapter(), _adapter()
|
||
pC_a, pC_b = _adapter(), _adapter()
|
||
patches = {
|
||
"a": [_entry(d, pX), _entry(s, pB_a), _entry(s, pC_a)],
|
||
"b": [_entry(s, pB_b), _entry(s, pC_b)],
|
||
}
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 3)
|
||
self.assertEqual(set(groups[0]["entries"]), {"a"})
|
||
self.assertEqual(set(groups[1]["entries"]), {"a", "b"})
|
||
self.assertEqual(set(groups[2]["entries"]), {"a", "b"})
|
||
# ordinal-k entries belong to the k-th colliding call on EVERY key
|
||
self.assertIs(groups[1]["entries"]["a"], pB_a)
|
||
self.assertIs(groups[1]["entries"]["b"], pB_b)
|
||
self.assertIs(groups[2]["entries"]["a"], pC_a)
|
||
self.assertIs(groups[2]["entries"]["b"], pC_b)
|
||
|
||
def test_collision_subgroup_reordered_by_precedence(self):
|
||
# Chain: two interned-strength calls A, B on "a", then distinct call D
|
||
# on {z, a}. Dict iteration starts at "z", so D's group is CREATED
|
||
# first; both collision groups (base + ordinal sub-group) must be
|
||
# sorted forward past D via the shared-key "a" positions — this
|
||
# exercises the insertion sort on a collision-created sub-group.
|
||
s = 1.0
|
||
d = struct.unpack("d", struct.pack("d", 0.5))[0]
|
||
pA, pB, pD_a, pD_z = _adapter(), _adapter(), _adapter(), _adapter()
|
||
patches = {
|
||
"z": [_entry(d, pD_z)],
|
||
"a": [_entry(s, pA), _entry(s, pB), _entry(d, pD_a)],
|
||
}
|
||
groups = self._reconstruct(patches)
|
||
self.assertEqual(len(groups), 3)
|
||
self.assertIs(groups[0]["entries"]["a"], pA)
|
||
self.assertIs(groups[1]["entries"]["a"], pB)
|
||
self.assertEqual(set(groups[2]["entries"]), {"z", "a"})
|
||
|
||
|
||
class _FakePatcher:
|
||
"""Minimal ModelPatcher stand-in: ordered patch lists + clone()."""
|
||
def __init__(self, patches=None):
|
||
self.patches = patches if patches is not None else {}
|
||
self.patches_uuid = object()
|
||
|
||
def clone(self):
|
||
return _FakePatcher({k: v[:] for k, v in self.patches.items()})
|
||
|
||
|
||
class TestStripCaptured(unittest.TestCase):
|
||
def test_strips_only_captured_entries(self):
|
||
keep = _entry(1.0, ("set", (torch.zeros(2, 2),)))
|
||
patches = _chain_patches((0.8, {"a": _adapter(), "b": _adapter()}))
|
||
patches["a"].append(keep)
|
||
patcher = _FakePatcher(patches)
|
||
groups = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(patcher.patches)
|
||
clone = patcher.clone()
|
||
lora_optimizer._LoRAMergeBase._strip_captured_entries(clone, groups)
|
||
self.assertEqual(clone.patches, {"a": [keep]})
|
||
self.assertEqual(len(patcher.patches["a"]), 2) # original untouched
|
||
|
||
def test_uuid_regenerated(self):
|
||
patcher = _FakePatcher(_chain_patches((0.8, {"a": _adapter()})))
|
||
groups = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(patcher.patches)
|
||
clone = patcher.clone()
|
||
before = clone.patches_uuid
|
||
lora_optimizer._LoRAMergeBase._strip_captured_entries(clone, groups)
|
||
self.assertNotEqual(clone.patches_uuid, before)
|
||
|
||
|
||
def _slot(enabled=True, strength=1.0, model_strength=1.0, clip_strength=1.0,
|
||
conflict_mode="all", key_filter="all", preserve=False):
|
||
return dict(enabled=enabled, strength=strength, model_strength=model_strength,
|
||
clip_strength=clip_strength, conflict_mode=conflict_mode,
|
||
key_filter=key_filter, preserve=preserve)
|
||
|
||
|
||
class TestChainStackBuild(unittest.TestCase):
|
||
def _build(self, model_groups, clip_groups, slots, visibility="simple"):
|
||
return lora_optimizer.LoRAOptimizerInline._chain_groups_to_stack(
|
||
model_groups, clip_groups, slots, visibility)
|
||
|
||
def test_basic_item_schema(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
stack = self._build(mg, [], [_slot()])
|
||
item = stack[0]
|
||
self.assertTrue(item["_precomputed_diffs"])
|
||
self.assertAlmostEqual(item["strength"], 0.8) # loader strength kept
|
||
self.assertIsNone(item["clip_strength"])
|
||
self.assertEqual(item["conflict_mode"], "all")
|
||
self.assertIn("a", item["lora"])
|
||
|
||
def test_simple_mode_multiplier(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
stack = self._build(mg, [], [_slot(strength=0.5)])
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.4) # 0.8 loader × 0.5 slot
|
||
|
||
def test_advanced_mode_split_multipliers(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()})))
|
||
stack = self._build(mg, cg, [_slot(model_strength=0.5, clip_strength=2.0)],
|
||
visibility="advanced")
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.4) # 0.8 × 0.5
|
||
self.assertAlmostEqual(stack[0]["clip_strength"], 1.2) # 0.6 × 2.0
|
||
self.assertIn("te.a", stack[0]["lora"]) # clip keys merged in
|
||
|
||
def test_disabled_slot_excluded(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
stack = self._build(mg, [], [_slot(enabled=False), _slot()])
|
||
self.assertEqual(len(stack), 1)
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.5)
|
||
|
||
def test_missing_slots_get_defaults(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
stack = self._build(mg, [], [_slot(preserve=True)]) # only 1 slot for 2 loras
|
||
self.assertEqual(len(stack), 2)
|
||
self.assertTrue(stack[0]["preserve"])
|
||
self.assertFalse(stack[1]["preserve"])
|
||
|
||
def test_leftover_clip_groups_become_clip_only_items(self):
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()})))
|
||
stack = self._build([], cg, [])
|
||
self.assertEqual(len(stack), 1)
|
||
self.assertIn("te.a", stack[0]["lora"])
|
||
|
||
def test_leftover_strength_is_clip_product_nonzero(self):
|
||
# the engine drops strength == 0 items (active_loras filter in
|
||
# optimize_merge) — clip-only items must ride on the clip product
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()})))
|
||
stack = self._build([], cg, [])
|
||
self.assertNotEqual(stack[0]["strength"], 0.0)
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.6)
|
||
self.assertAlmostEqual(stack[0]["clip_strength"], 0.6)
|
||
|
||
def test_zeroed_model_branch_keeps_live_clip_branch(self):
|
||
# stock-UI case: LoraLoader with strength_model=0, strength_clip=1.0
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()})))
|
||
stack = self._build(mg, cg, [_slot(model_strength=0.0, clip_strength=1.0)],
|
||
visibility="advanced")
|
||
self.assertEqual(len(stack), 1)
|
||
self.assertNotIn("a", stack[0]["lora"]) # unet keys dropped
|
||
self.assertIn("te.a", stack[0]["lora"])
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.6) # clip product
|
||
self.assertAlmostEqual(stack[0]["clip_strength"], 0.6)
|
||
|
||
def test_both_branches_zeroed_item_excluded(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()})))
|
||
stack = self._build(mg, cg, [_slot(model_strength=0.0, clip_strength=0.0)],
|
||
visibility="advanced")
|
||
self.assertEqual(stack, [])
|
||
|
||
def test_leftover_after_model_groups_uses_own_slot_index(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()}), (0.4, {"te.b": _adapter()})))
|
||
stack = self._build(mg, cg, [_slot(), _slot()])
|
||
self.assertEqual(len(stack), 2)
|
||
self.assertEqual(stack[1]["name"], "chain lora #2 (clip-only)")
|
||
self.assertIn("te.b", stack[1]["lora"])
|
||
self.assertAlmostEqual(stack[1]["strength"], 0.4)
|
||
# slot index j (not 0) gates the leftover — disabling slot 2 drops it
|
||
stack = self._build(mg, cg, [_slot(), _slot(enabled=False)])
|
||
self.assertEqual(len(stack), 1)
|
||
self.assertNotIn("(clip-only)", stack[0]["name"])
|
||
|
||
def test_simple_visibility_scales_paired_clip(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
cg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.6, {"te.a": _adapter()})))
|
||
stack = self._build(mg, cg, [_slot(strength=0.5)]) # simple visibility
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.4) # 0.8 × 0.5
|
||
self.assertAlmostEqual(stack[0]["clip_strength"], 0.3) # 0.6 × 0.5
|
||
|
||
def test_extra_slots_ignored(self):
|
||
mg = lora_optimizer._LoRAMergeBase._reconstruct_chain_groups(
|
||
_chain_patches((0.8, {"a": _adapter()})))
|
||
stack = self._build(mg, [], [_slot(), _slot(strength=9.0), _slot(enabled=False)])
|
||
self.assertEqual(len(stack), 1)
|
||
self.assertAlmostEqual(stack[0]["strength"], 0.8)
|
||
|
||
|
||
class _FakeCLIP:
|
||
def __init__(self, patcher):
|
||
self.patcher = patcher
|
||
self.cond_stage_model = types.SimpleNamespace()
|
||
|
||
def clone(self):
|
||
return _FakeCLIP(self.patcher.clone())
|
||
|
||
|
||
class TestInlineExecute(unittest.TestCase):
|
||
def _node(self):
|
||
return lora_optimizer.LoRAOptimizerInline()
|
||
|
||
def _model(self, patches):
|
||
m = _FakePatcher(patches)
|
||
m.model = types.SimpleNamespace()
|
||
return m
|
||
|
||
@staticmethod
|
||
def _out(result):
|
||
return result["result"] if isinstance(result, dict) else result
|
||
|
||
def _capture_merge(self, node, seen):
|
||
"""Mock optimize_merge, recording every kwarg + the stack/model."""
|
||
def fake_merge(m, stack, output_strength, **kw):
|
||
seen["model"] = m
|
||
seen["stack"] = stack
|
||
seen["output_strength"] = output_strength
|
||
seen.update(kw)
|
||
return (m, kw.get("clip"), "engine report", None, None)
|
||
node.optimize_merge = fake_merge
|
||
|
||
def test_no_lora_patches_passthrough(self):
|
||
model = self._model({})
|
||
node = self._node()
|
||
result = node.execute_inline(model, output_strength=1.0)
|
||
out = self._out(result)
|
||
self.assertIs(out[0], model) # unchanged object back
|
||
self.assertIn("No LoRA patches", out[2])
|
||
self.assertIn("AFTER your Load LoRA nodes", out[2])
|
||
self.assertIsNone(out[3])
|
||
self.assertIsNone(out[4])
|
||
|
||
def test_non_mergeable_only_patches_reported_honestly(self):
|
||
# Patches exist but none are capturable (OFT/"set"/function entries):
|
||
# "place this node AFTER your loaders" would be WRONG advice.
|
||
patches = {"a": [_entry(1.0, ("set", (torch.zeros(2, 2),))),
|
||
_entry(1.0, _adapter(), function=lambda w: w)]}
|
||
model = self._model(patches)
|
||
node = self._node()
|
||
result = node.execute_inline(model, output_strength=1.0)
|
||
out = self._out(result)
|
||
self.assertIs(out[0], model)
|
||
self.assertIn("2 non-mergeable patch entries", out[2])
|
||
self.assertIn("passed through", out[2])
|
||
self.assertNotIn("AFTER your Load LoRA nodes", out[2])
|
||
|
||
def test_merge_called_with_virtual_stack_and_stripped_model(self):
|
||
model = self._model(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"a": _adapter()})))
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
node.execute_inline(model, output_strength=1.0)
|
||
self.assertEqual(len(seen["stack"]), 2)
|
||
self.assertTrue(all(i["_precomputed_diffs"] for i in seen["stack"]))
|
||
self.assertEqual(seen["model"].patches, {}) # stripped clone
|
||
self.assertIsNot(seen["model"], model)
|
||
self.assertEqual(seen["normalize_keys"], "disabled")
|
||
|
||
def test_report_prepends_fingerprints(self):
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter(), "b": _adapter()})))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "engine report", None, None)
|
||
result = node.execute_inline(model, output_strength=1.0)
|
||
report = self._out(result)[2]
|
||
self.assertIn("#1", report)
|
||
self.assertIn("2 keys", report)
|
||
self.assertIn("0.80", report)
|
||
self.assertIn("rank 4", report)
|
||
self.assertIn("engine report", report)
|
||
|
||
def test_disabled_slot_stripped_but_not_merged(self):
|
||
model = self._model(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
node.execute_inline(model, output_strength=1.0, enabled_1=False)
|
||
self.assertEqual(len(seen["stack"]), 1)
|
||
self.assertAlmostEqual(seen["stack"][0]["strength"], 0.5)
|
||
self.assertEqual(seen["model"].patches, {}) # disabled LoRA stripped too
|
||
|
||
def test_fingerprint_warns_on_group_count_mismatch(self):
|
||
# 2 model groups vs 1 clip group (both non-empty) -> order-attribution
|
||
# warning naming the usual suspects
|
||
model = self._model(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
clip = _FakeCLIP(_FakePatcher(_chain_patches((0.6, {"te.a": _adapter()}))))
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
result = node.execute_inline(model, output_strength=1.0, clip=clip)
|
||
report = self._out(result)[2]
|
||
self.assertIn("2 model-side vs 1 clip-side", report)
|
||
self.assertIn("LoraLoaderModelOnly", report)
|
||
# the clip clone passed to the engine is stripped, original untouched
|
||
self.assertIsNot(seen["clip"], clip)
|
||
self.assertEqual(seen["clip"].patcher.patches, {})
|
||
self.assertEqual(len(clip.patcher.patches["te.a"]), 1)
|
||
|
||
def test_fingerprint_counts_passthrough_entries(self):
|
||
patches = _chain_patches((0.8, {"a": _adapter()}))
|
||
patches["a"].append(_entry(1.0, ("set", (torch.zeros(2, 2),))))
|
||
model = self._model(patches)
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "r", None, None)
|
||
result = node.execute_inline(model, output_strength=1.0)
|
||
report = self._out(result)[2]
|
||
self.assertIn("1 non-LoRA", report)
|
||
self.assertIn("passed through untouched", report)
|
||
|
||
def test_no_passthrough_note_when_all_captured(self):
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "r", None, None)
|
||
report = self._out(node.execute_inline(model, output_strength=1.0))[2]
|
||
self.assertNotIn("non-LoRA", report)
|
||
|
||
def test_fingerprint_notes_fewer_slots_than_loras(self):
|
||
model = self._model(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "r", None, None)
|
||
report = self._out(node.execute_inline(
|
||
model, output_strength=1.0, lora_count=1))[2]
|
||
self.assertIn("2 LoRAs detected but only 1 option slot", report)
|
||
self.assertIn("default options", report)
|
||
|
||
def test_fingerprint_notes_more_slots_than_loras(self):
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "r", None, None)
|
||
report = self._out(node.execute_inline(
|
||
model, output_strength=1.0, lora_count=3))[2]
|
||
self.assertIn("3 option slots but only 1 LoRA", report)
|
||
self.assertIn("extra slots ignored", report)
|
||
|
||
def test_autotuner_settings_fall_back_to_defaults(self):
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
result = node.execute_inline(model, output_strength=1.0,
|
||
settings={"mode": "autotuner", "top_n": 3})
|
||
report = self._out(result)[2]
|
||
self.assertIn("AutoTuner settings are not supported inline", report)
|
||
self.assertNotIn("top_n", seen) # defaults used
|
||
self.assertEqual(seen["normalize_keys"], "disabled")
|
||
|
||
def test_advanced_settings_delegated_normalize_keys_pinned(self):
|
||
settings = {
|
||
"mode": "advanced",
|
||
"auto_strength": "disabled", "auto_strength_floor": 0.5,
|
||
"optimization_mode": "global",
|
||
"sparsification": "dare", "sparsification_density": 0.5,
|
||
"dare_dampening": 0.1,
|
||
"merge_refinement": "refine", "strategy_set": "basic",
|
||
"normalize_keys": "enabled", # must NOT reach the engine
|
||
"architecture_preset": "dit", "decision_smoothing": 0.1,
|
||
"smooth_slerp_gate": True,
|
||
"star_eta": 0.9, "tame_layers": 0.1, "tame_threshold": 0.2,
|
||
"cache_patches": "disabled", "patch_compression": "disabled",
|
||
"svd_device": "cpu", "free_vram_between_passes": "enabled",
|
||
"vram_budget": 0.25,
|
||
}
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
result = node.execute_inline(model, output_strength=1.0, settings=settings)
|
||
report = self._out(result)[2]
|
||
self.assertNotIn("not supported inline", report)
|
||
self.assertEqual(seen["normalize_keys"], "disabled") # pinned
|
||
self.assertEqual(seen["optimization_mode"], "global")
|
||
self.assertEqual(seen["sparsification"], "dare")
|
||
self.assertAlmostEqual(seen["sparsification_density"], 0.5)
|
||
self.assertEqual(seen["cache_patches"], "disabled")
|
||
self.assertEqual(seen["svd_device"], "cpu")
|
||
self.assertAlmostEqual(seen["star_eta"], 0.9)
|
||
# explicit preset from the settings node -> no arch-unknown warning
|
||
self.assertNotIn("architecture: unknown", report)
|
||
|
||
def test_fingerprint_warns_arch_unknown_without_preset(self):
|
||
# Virtual items skip _normalize_stack's arch detection, so auto
|
||
# detection can never resolve — the report must say so.
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "r", None, None)
|
||
report = self._out(node.execute_inline(model, output_strength=1.0))[2]
|
||
self.assertIn("architecture: unknown (inline capture)", report)
|
||
self.assertIn("architecture_preset", report)
|
||
|
||
def test_passthrough_count_includes_clip_side(self):
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
clip_patches = _chain_patches((0.6, {"te.a": _adapter()}))
|
||
clip_patches["te.a"].append(_entry(1.0, ("set", (torch.zeros(2, 2),))))
|
||
clip = _FakeCLIP(_FakePatcher(clip_patches))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, kw.get("clip"), "r", None, None)
|
||
report = self._out(node.execute_inline(model, output_strength=1.0,
|
||
clip=clip))[2]
|
||
self.assertIn("1 non-LoRA", report) # the clip-side "set" entry
|
||
self.assertIn("passed through untouched", report)
|
||
|
||
def test_lokr_payload_reports_dense_rank(self):
|
||
# LoKr weights[1] is the w2 Kronecker factor, NOT a rank — the
|
||
# fingerprint must not misreport its shape as "rank N".
|
||
lokr = lora_optimizer.LoKrAdapter(
|
||
loaded_keys=set(),
|
||
weights=(torch.randn(4, 4), torch.randn(2, 2), None, None,
|
||
None, None, None, None))
|
||
model = self._model(_chain_patches((0.8, {"a": lokr})))
|
||
node = self._node()
|
||
node.optimize_merge = lambda m, s, o, **kw: (m, None, "r", None, None)
|
||
report = self._out(node.execute_inline(model, output_strength=1.0))[2]
|
||
self.assertIn("dense", report)
|
||
self.assertNotIn("rank 2", report)
|
||
|
||
def test_advanced_visibility_widgets_reach_stack(self):
|
||
model = self._model(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
clip = _FakeCLIP(_FakePatcher(_chain_patches(
|
||
(0.6, {"te.a": _adapter()}), (0.4, {"te.b": _adapter()}))))
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
node.execute_inline(model, output_strength=1.0, clip=clip,
|
||
settings_visibility="advanced",
|
||
model_strength_2=0.5, clip_strength_2=2.0)
|
||
self.assertEqual(len(seen["stack"]), 2)
|
||
self.assertAlmostEqual(seen["stack"][0]["strength"], 0.8) # defaults
|
||
self.assertAlmostEqual(seen["stack"][0]["clip_strength"], 0.6)
|
||
self.assertAlmostEqual(seen["stack"][1]["strength"], 0.25) # 0.5 x 0.5
|
||
self.assertAlmostEqual(seen["stack"][1]["clip_strength"], 0.8) # 0.4 x 2.0
|
||
|
||
def test_salt_names_differ_across_source_patchers(self):
|
||
# Two DIFFERENT loader chains at identical strengths must yield
|
||
# different virtual item names, or optimize_merge's in-node cache
|
||
# would false-hit when the user swaps a LoRA file.
|
||
names_per_run = []
|
||
for _ in range(2):
|
||
model = self._model(_chain_patches(
|
||
(0.8, {"a": _adapter()}), (0.5, {"b": _adapter()})))
|
||
model.patches_uuid = uuid.uuid4()
|
||
node = self._node()
|
||
seen = {}
|
||
self._capture_merge(node, seen)
|
||
node.execute_inline(model, output_strength=1.0)
|
||
names_per_run.append([i["name"] for i in seen["stack"]])
|
||
self.assertNotEqual(names_per_run[0], names_per_run[1])
|
||
for names in names_per_run:
|
||
self.assertTrue(names[0].startswith("chain lora #1"))
|
||
self.assertTrue(names[1].startswith("chain lora #2"))
|
||
|
||
def test_salt_names_stable_for_same_patcher(self):
|
||
model = self._model(_chain_patches((0.8, {"a": _adapter()})))
|
||
model.patches_uuid = uuid.uuid4()
|
||
node = self._node()
|
||
names_per_run = []
|
||
def fake_merge(m, stack, o, **kw):
|
||
names_per_run.append([i["name"] for i in stack])
|
||
return (m, None, "r", None, None)
|
||
node.optimize_merge = fake_merge
|
||
node.execute_inline(model, output_strength=1.0)
|
||
node.execute_inline(model, output_strength=1.0)
|
||
self.assertEqual(names_per_run[0], names_per_run[1])
|
||
|
||
def test_input_types_surface(self):
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
it = cls.INPUT_TYPES()
|
||
req, opt = it["required"], it["optional"]
|
||
# widgets the user types/toggles live in "required" (optional widget
|
||
# inputs render as sockets in some frontends); JS control widgets
|
||
# must all be present and required
|
||
for w in ("model", "settings_visibility", "lora_count",
|
||
"output_strength", "clip_strength_multiplier"):
|
||
self.assertIn(w, req)
|
||
for i in (1, cls.MAX_LORAS):
|
||
for base in ("enabled", "strength", "model_strength",
|
||
"clip_strength", "conflict_mode", "key_filter",
|
||
"preserve"):
|
||
self.assertIn(f"{base}_{i}", req)
|
||
# genuine node-to-node wires stay optional
|
||
self.assertIn("clip", opt)
|
||
self.assertIn("settings", opt)
|
||
self.assertEqual(cls.FUNCTION, "execute_inline")
|
||
# inherited from LoRAOptimizer — lora_data keeps SaveMergedLoRA working
|
||
self.assertEqual(cls.RETURN_TYPES,
|
||
("MODEL", "CLIP", "STRING", "TUNER_DATA", "LORA_DATA"))
|
||
self.assertNotIn("RETURN_TYPES", vars(cls)) # not redeclared
|
||
|
||
|
||
class TestInlineIsChanged(unittest.TestCase):
|
||
"""LoRAOptimizerInline.IS_CHANGED must accept the node's ACTUAL inputs
|
||
(ComfyUI passes every declared widget; the inherited LoRAOptimizer
|
||
signature expects lora_stack and would raise -> node re-merges on every
|
||
queue press) and key on the upstream patcher state + consulted widgets."""
|
||
|
||
@staticmethod
|
||
def _full_widget_kwargs():
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
kw = {"settings_visibility": "simple", "lora_count": 3,
|
||
"output_strength": 1.0, "clip_strength_multiplier": 1.0}
|
||
for i in range(1, cls.MAX_LORAS + 1):
|
||
kw[f"enabled_{i}"] = True
|
||
kw[f"strength_{i}"] = 1.0
|
||
kw[f"model_strength_{i}"] = 1.0
|
||
kw[f"clip_strength_{i}"] = 1.0
|
||
kw[f"conflict_mode_{i}"] = "all"
|
||
kw[f"key_filter_{i}"] = "all"
|
||
kw[f"preserve_{i}"] = False
|
||
return kw
|
||
|
||
def _model(self):
|
||
m = _FakePatcher(_chain_patches((0.8, {"a": _adapter()})))
|
||
m.patches_uuid = uuid.uuid4()
|
||
return m
|
||
|
||
def test_accepts_full_widget_set_and_is_stable(self):
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
model = self._model()
|
||
kw = self._full_widget_kwargs()
|
||
first = cls.IS_CHANGED(model, **kw) # must not raise
|
||
self.assertEqual(first, cls.IS_CHANGED(model, **kw))
|
||
|
||
def test_changes_when_consulted_widget_changes(self):
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
model = self._model()
|
||
kw = self._full_widget_kwargs()
|
||
first = cls.IS_CHANGED(model, **kw)
|
||
changed = dict(kw, enabled_2=False) # slot 2 <= lora_count=3
|
||
self.assertNotEqual(first, cls.IS_CHANGED(model, **changed))
|
||
|
||
def test_ignores_widgets_beyond_lora_count(self):
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
model = self._model()
|
||
kw = self._full_widget_kwargs()
|
||
first = cls.IS_CHANGED(model, **kw)
|
||
beyond = dict(kw, strength_9=0.25) # slot 9 > lora_count=3
|
||
self.assertEqual(first, cls.IS_CHANGED(model, **beyond))
|
||
|
||
def test_changes_when_upstream_chain_changes(self):
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
model = self._model()
|
||
kw = self._full_widget_kwargs()
|
||
first = cls.IS_CHANGED(model, **kw)
|
||
model.patches_uuid = uuid.uuid4() # loader chain re-executed
|
||
self.assertNotEqual(first, cls.IS_CHANGED(model, **kw))
|
||
|
||
def test_changes_with_clip_and_settings(self):
|
||
cls = lora_optimizer.LoRAOptimizerInline
|
||
model = self._model()
|
||
kw = self._full_widget_kwargs()
|
||
first = cls.IS_CHANGED(model, **kw)
|
||
clip = _FakeCLIP(_FakePatcher(_chain_patches((0.6, {"te.a": _adapter()}))))
|
||
clip.patcher.patches_uuid = uuid.uuid4()
|
||
with_clip = cls.IS_CHANGED(model, clip=clip, **kw)
|
||
self.assertNotEqual(first, with_clip)
|
||
clip.patcher.patches_uuid = uuid.uuid4()
|
||
self.assertNotEqual(with_clip, cls.IS_CHANGED(model, clip=clip, **kw))
|
||
self.assertNotEqual(first, cls.IS_CHANGED(
|
||
model, settings={"mode": "advanced"}, **kw))
|
||
|
||
|
||
def _realistic_load_lora_for_models(calls):
|
||
"""A comfy.sd.load_lora_for_models stand-in that mimics the REAL failure
|
||
mode instead of blindly passing the model through: it resolves only
|
||
TRAINER-format keys ({x}.lora_up.weight / .lora_down.weight). Virtual
|
||
chain items carry model-target keys, so nothing matches and the models
|
||
come back unchanged — exactly what live comfy does, silently."""
|
||
def stub(model, clip, lora_dict, strength_model, strength_clip):
|
||
matched = [k for k in lora_dict
|
||
if isinstance(k, str) and (k.endswith(".lora_up.weight")
|
||
or k.endswith(".lora_down.weight"))]
|
||
calls.append({"matched": matched, "n_keys": len(lora_dict)})
|
||
return (model, clip) # nothing baked for unmatched keys
|
||
return stub
|
||
|
||
|
||
def _pipeline_model(patches, applied, dim=16):
|
||
"""Fake ModelPatcher threading the REAL optimize_merge pipeline: a .model
|
||
with a dim x dim base weight so _resolve_target_shape/_resolve_base_norm
|
||
work, faithful clone() (copies the CURRENT patch lists, like the real
|
||
ModelPatcher.clone — so a clone of the stripped clone stays stripped),
|
||
and an add_patches recorder capturing the merged patches the engine
|
||
re-applies (plus which patcher received them)."""
|
||
base = types.SimpleNamespace(
|
||
layer=types.SimpleNamespace(weight=torch.zeros(dim, dim)))
|
||
|
||
def _attach(p):
|
||
p.model = base
|
||
p.add_patches = lambda patches_, strength=1.0, strength_clip=None: (
|
||
applied.update(patches=dict(patches_), strength=strength,
|
||
patcher=p),
|
||
list(patches_.keys()))[1]
|
||
p.clone = lambda: _attach(
|
||
_FakePatcher({k: v[:] for k, v in p.patches.items()}))
|
||
return p
|
||
|
||
return _attach(_FakePatcher(patches))
|
||
|
||
|
||
class TestSingleLoraVirtualPath(unittest.TestCase):
|
||
"""A 1-LoRA chain (or N LoRAs with all but one disabled) must NOT take
|
||
optimize_merge's single-LoRA fast path: load_lora_for_models looks up
|
||
trainer-format keys, virtual items carry model-target keys, so it would
|
||
load NOTHING — and with the originals stripped, the output model would
|
||
silently lose the LoRA entirely."""
|
||
|
||
def _fake_model(self, patches, applied):
|
||
return _pipeline_model(patches, applied)
|
||
|
||
def test_single_virtual_lora_goes_through_pipeline(self):
|
||
key = "layer.weight"
|
||
applied = {}
|
||
model = self._fake_model(
|
||
_chain_patches((0.9, {key: _adapter(rank=4, out_dim=16, in_dim=16)})),
|
||
applied)
|
||
node = lora_optimizer.LoRAOptimizerInline()
|
||
node._get_model_keys = lambda m: {"alias_layer": key}
|
||
calls = []
|
||
with mock.patch.object(lora_optimizer.comfy.sd, "load_lora_for_models",
|
||
_realistic_load_lora_for_models(calls)):
|
||
result = node.execute_inline(model, output_strength=1.0)
|
||
out = result["result"] if isinstance(result, dict) else result
|
||
self.assertEqual(calls, []) # fast path NOT taken
|
||
self.assertTrue(applied.get("patches")) # pipeline re-applied patches
|
||
self.assertIn("chain lora #1", out[2])
|
||
|
||
def test_disabled_down_to_single_also_uses_pipeline(self):
|
||
key = "layer.weight"
|
||
applied = {}
|
||
model = self._fake_model(
|
||
_chain_patches((0.9, {key: _adapter(rank=4, out_dim=16, in_dim=16)}),
|
||
(0.5, {key: _adapter(rank=4, out_dim=16, in_dim=16)})),
|
||
applied)
|
||
node = lora_optimizer.LoRAOptimizerInline()
|
||
node._get_model_keys = lambda m: {"alias_layer": key}
|
||
calls = []
|
||
with mock.patch.object(lora_optimizer.comfy.sd, "load_lora_for_models",
|
||
_realistic_load_lora_for_models(calls)):
|
||
node.execute_inline(model, output_strength=1.0, enabled_2=False)
|
||
self.assertEqual(calls, [])
|
||
self.assertTrue(applied.get("patches"))
|
||
|
||
def test_plain_single_lora_stack_still_takes_fast_path(self):
|
||
# Regression net for the guard: trainer-format single-LoRA stacks
|
||
# keep the fast path, and the realistic stub CAN match their keys.
|
||
model = _FakePatcher({})
|
||
model.model = types.SimpleNamespace(
|
||
layer=types.SimpleNamespace(weight=torch.zeros(16, 16)))
|
||
stack = [{"name": "A", "strength": 1.0,
|
||
"lora": {"alias_layer.lora_up.weight": torch.randn(16, 4),
|
||
"alias_layer.lora_down.weight": torch.randn(4, 16),
|
||
"alias_layer.alpha": torch.tensor(4.0)}}]
|
||
node = lora_optimizer.LoRAOptimizer()
|
||
node._get_model_keys = lambda m: {"alias_layer": "layer.weight"}
|
||
calls = []
|
||
with mock.patch.object(lora_optimizer.comfy.sd, "load_lora_for_models",
|
||
_realistic_load_lora_for_models(calls)):
|
||
node.optimize_merge(model, stack, 1.0)
|
||
self.assertEqual(len(calls), 1)
|
||
self.assertTrue(calls[0]["matched"])
|
||
|
||
|
||
def _advanced_settings(**overrides):
|
||
"""Full OPTIMIZER_SETTINGS advanced-mode dict with deterministic,
|
||
test-friendly engine knobs (no merge cache, no compression SVD, CPU SVD).
|
||
The settings input is the node's supported surface for engine kwargs —
|
||
execute_inline deliberately does not forward loose **kwargs to
|
||
optimize_merge."""
|
||
settings = {
|
||
"mode": "advanced",
|
||
"auto_strength": "disabled", "auto_strength_floor": -1.0,
|
||
"optimization_mode": "per_prefix",
|
||
"sparsification": "disabled", "sparsification_density": 0.7,
|
||
"dare_dampening": 0.0,
|
||
"merge_strategy_override": "",
|
||
"merge_refinement": "none", "strategy_set": "full",
|
||
"normalize_keys": "disabled",
|
||
"architecture_preset": "dit", "decision_smoothing": 0.25,
|
||
"smooth_slerp_gate": False,
|
||
"star_eta": 100.0, "tame_layers": 0.0, "tame_threshold": 0.3,
|
||
"cache_patches": "disabled", "patch_compression": "disabled",
|
||
"svd_device": "cpu", "free_vram_between_passes": "disabled",
|
||
"vram_budget": 0.0,
|
||
}
|
||
settings.update(overrides)
|
||
return settings
|
||
|
||
|
||
class TestMultiLoraEndToEnd(unittest.TestCase):
|
||
"""Captured MULTI-LoRA chains through the REAL optimize_merge: two rank-4
|
||
adapters on the same 16x16 layer key, different loader strengths. The
|
||
single-LoRA virtual path is covered above; this closes the >= 2-LoRA gap
|
||
(full Pass 1 conflict analysis + Pass 2 group merge on virtual items)."""
|
||
|
||
KEY = "layer.weight"
|
||
|
||
@staticmethod
|
||
def _det_mats(seed_row):
|
||
"""Deterministic full-rank-ish up/down matrices (no randn luck)."""
|
||
up = torch.linspace(-1.0 + seed_row, 1.0 + seed_row, 16 * 4).reshape(16, 4)
|
||
down = torch.linspace(0.5 - seed_row, -0.5 + seed_row, 4 * 16).reshape(4, 16)
|
||
return up, down
|
||
|
||
def _run(self, chain, settings=None, **exec_kwargs):
|
||
applied = {}
|
||
model = _pipeline_model(_chain_patches(*chain), applied)
|
||
node = lora_optimizer.LoRAOptimizerInline()
|
||
node._get_model_keys = lambda m: {"alias_layer": self.KEY}
|
||
calls = []
|
||
with mock.patch.object(lora_optimizer.comfy.sd, "load_lora_for_models",
|
||
_realistic_load_lora_for_models(calls)):
|
||
result = node.execute_inline(model, output_strength=1.0,
|
||
settings=settings, **exec_kwargs)
|
||
out = result["result"] if isinstance(result, dict) else result
|
||
return model, applied, calls, out
|
||
|
||
def test_two_lora_chain_weighted_sum_is_numerically_exact(self):
|
||
up_a, down_a = self._det_mats(0.0)
|
||
up_b, down_b = self._det_mats(0.25)
|
||
chain = (
|
||
(1.0, {self.KEY: _adapter(up=up_a, down=down_a)}),
|
||
(0.7, {self.KEY: _adapter(up=up_b, down=down_b)}),
|
||
)
|
||
# weighted_sum override makes the expected result exact:
|
||
# merged = 1.0 * (up_a @ down_a) + 0.7 * (up_b @ down_b)
|
||
settings = _advanced_settings(optimization_mode="global",
|
||
merge_strategy_override="weighted_sum")
|
||
model, applied, calls, out = self._run(chain, settings=settings)
|
||
orig_entries = list(model.patches[self.KEY])
|
||
|
||
# (a) report opens with the chain fingerprints, engine report follows
|
||
report = out[2]
|
||
self.assertIn("#1: 1 keys, rank 4, loader strength 1.00", report)
|
||
self.assertIn("#2: 1 keys, rank 4, loader strength 0.70", report)
|
||
self.assertIn("chain lora #1", report)
|
||
|
||
# (b) merged patches re-applied via add_patches on a clone
|
||
self.assertEqual(calls, []) # no fast-path bypass
|
||
self.assertIn("patches", applied)
|
||
self.assertIsNot(applied["patcher"], model)
|
||
self.assertIs(out[0], applied["patcher"]) # patched clone returned
|
||
self.assertAlmostEqual(applied["strength"], 1.0)
|
||
|
||
# (c) the applied patch is the exact weighted combination
|
||
self.assertEqual(set(applied["patches"]), {self.KEY})
|
||
got = lora_optimizer._LoRAMergeBase._expand_patch_to_diff(
|
||
applied["patches"][self.KEY])
|
||
expected = up_a @ down_a + 0.7 * (up_b @ down_b)
|
||
self.assertTrue(torch.allclose(got, expected, atol=1e-5),
|
||
f"max err {(got - expected).abs().max().item()}")
|
||
|
||
# (d) the original input model still carries its loader entries
|
||
self.assertEqual(model.patches[self.KEY], orig_entries)
|
||
self.assertEqual(len(model.patches[self.KEY]), 2)
|
||
|
||
def test_conflicting_loras_complete_through_conflict_analysis(self):
|
||
# Opposite-sign directions built deliberately: B is A negated with a
|
||
# small deterministic perturbation (near-antiparallel, cos ~ -1) so
|
||
# the per_prefix conflict path really engages without relying on a
|
||
# degenerate exactly-antiparallel corner.
|
||
up_a, down_a = self._det_mats(0.0)
|
||
down_b = down_a.clone()
|
||
down_b[0] += 0.05
|
||
chain = (
|
||
(1.0, {self.KEY: _adapter(up=up_a, down=down_a)}),
|
||
(0.7, {self.KEY: _adapter(up=-up_a, down=down_b)}),
|
||
)
|
||
model, applied, calls, out = self._run(chain, settings=_advanced_settings())
|
||
|
||
self.assertEqual(calls, [])
|
||
report = out[2]
|
||
self.assertIn("#1: 1 keys, rank 4, loader strength 1.00", report)
|
||
self.assertIn("#2: 1 keys, rank 4, loader strength 0.70", report)
|
||
# merged patches exist and are finite/nonzero for the shared key
|
||
self.assertEqual(set(applied["patches"]), {self.KEY})
|
||
got = lora_optimizer._LoRAMergeBase._expand_patch_to_diff(
|
||
applied["patches"][self.KEY])
|
||
self.assertEqual(tuple(got.shape), (16, 16))
|
||
self.assertTrue(torch.isfinite(got).all())
|
||
self.assertGreater(got.abs().max().item(), 0.0)
|
||
# original chain untouched on the input model
|
||
self.assertEqual(len(model.patches[self.KEY]), 2)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|