feat(lora): per-block application works on any model, not just LTX

Per-block scaling is derived from the LoRA's own deltas — which blocks it put
energy into — so it is a property of the adapter, not of the architecture. Two
things made it LTX-only anyway:

The gate required image_model in {ltxv, ltxav} and mode ltx2, so a model
FunPack had not heard of got the global fallback without anything ever reading
its weights. Whether per-block can run is now discovered from the patches:
fewer than two numbered blocks and the existing fallback takes over, as before.

The key pattern only matched transformer_blocks.N. — MiniMax H3 names its
stack blocks.N., so even with the gate open every H3 patch counted as global
and per-block would have been a silent no-op. The pattern now covers blocks,
transformer_blocks, double_blocks, single_blocks, joint_blocks and layers.

The semantic anchors (PAG 14, STG 14/19) were absolute indices measured on
LTX's 28-block DiT and meant nothing on a stack of another depth. They are
positions now; a 28-block model still resolves to exactly 14 and 19, so LTX
behaviour is unchanged, and a deeper stack gets the equivalent blocks.

Declines, loudly, when a model numbers blocks in two separate stacks
(Flux-style double/single): both start at 0, so one scale would land on two
unrelated blocks.

Untested on a real model — local runs cannot load one. 13 new tests, 11 of
which fail against the previous code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
digital-garbage
2026-08-25 13:10:49 +03:00
co-authored by Claude Opus 5
parent 8739fa8d2e
commit 71f41259b3
2 changed files with 167 additions and 14 deletions
+74 -14
View File
@@ -24,7 +24,14 @@ except ImportError: # standalone tests import the modules directly
LORA_TYPES = ["general", "action", "style", "quality", "character"]
LORA_STACK_TYPE = "FUNPACK_LORA_STACK"
TRANSFORMER_BLOCK_PATTERN = re.compile(r"(?:^|\.)transformer_blocks\.(\d+)\.")
# Any numbered block container a diffusion transformer might use. Per-block application
# reads the LoRA's own deltas, which is a property of the adapter and not of the
# architecture — so the container name is the only thing that was ever model-specific, and
# it is a list rather than a gate. Longest names first so `transformer_blocks` is not
# matched as `blocks` (it cannot be, the preceding char is `_`, but the order says so).
TRANSFORMER_BLOCK_PATTERN = re.compile(
r"(?:^|\.)(transformer_blocks|double_blocks|single_blocks|joint_blocks|blocks|layers)"
r"\.(\d+)\.")
LTX_IMAGE_MODELS = {"ltxv", "ltxav"}
LORA_RAW_CACHE_SIZE = 12
LORA_PATCH_CACHE_SIZE = 24
@@ -48,9 +55,27 @@ LORA_TYPE_BLOCK_ZONES = {
}
LORA_ZONE_BOOST = 1.12
LORA_ZONE_SCALE_CAP = 1.75
# Blocks confirmed as primary semantic focal points (PAG default=14, STG defaults=14,19).
# Primary semantic focal points, as a POSITION along the stack rather than an index.
# PAG's default is block 14 and STG's are 14 and 19, both measured on LTX's 28-block DiT;
# 14/27 and 19/27 are those same blocks expressed as a fraction, so a 28-block model still
# resolves to exactly 14 and 19 while a model of any other depth gets the proportionally
# equivalent blocks instead of two indices that mean nothing there.
# These are never zeroed by type-zone suppression and never damped by stack pressure.
SEMANTIC_ANCHOR_BLOCKS = frozenset({14, 19})
SEMANTIC_ANCHOR_POSITIONS = (14.0 / 27.0, 19.0 / 27.0)
def semantic_anchor_blocks(block_indices):
"""The blocks nearest SEMANTIC_ANCHOR_POSITIONS in this model's own stack."""
indices = sorted(int(i) for i in block_indices)
if len(indices) < 2:
return frozenset(indices)
max_idx = max(indices)
if max_idx <= 0:
return frozenset(indices)
return frozenset(
min(indices, key=lambda i: abs(i / max_idx - pos))
for pos in SEMANTIC_ANCHOR_POSITIONS
)
@PromptServer.instance.routes.get("/funpack/loras")
@@ -170,7 +195,21 @@ def transformer_block_index(patch_key):
match = TRANSFORMER_BLOCK_PATTERN.search(target_key)
if not match:
return None
return int(match.group(1))
return int(match.group(2))
def block_container_name(patch_key):
"""Which numbered container this patch lives in, or None.
Two containers in one model (Flux's double_blocks/single_blocks) both start at 0, so
their indices would collide into one bucket and one scale would be applied to two
unrelated blocks. Per-block declines in that case rather than averaging them.
"""
target_key = patch_target_key(patch_key)
if not isinstance(target_key, str):
return None
match = TRANSFORMER_BLOCK_PATTERN.search(target_key)
return match.group(1) if match else None
def patch_energy(value):
@@ -755,13 +794,17 @@ class FunPackLoraLoader:
return coerce_bool(entry.get("per_block", lora_stack.get("per_block", False)))
def _per_block_supported(self, model, lora_stack, entry):
if not self._per_block_requested(entry, lora_stack):
return False
"""Whether to TRY per-block for this entry. Not a model whitelist any more.
if (lora_stack.get("mode") or "ltx2").lower() != "ltx2":
return False
return self._model_image_model(model) in LTX_IMAGE_MODELS
Per-block scaling is derived from the LoRA's own deltas — which blocks it actually
put energy into — so what decides whether it can run is whether this adapter names
numbered blocks, not which architecture it was trained for. That is discovered from
the patches themselves in _lora_block_profile, which returns None when there are
fewer than two blocks to compare; the caller then falls back to global and says so.
Refusing here by image_model meant a model FunPack had never heard of got the
fallback without anyone ever looking at its deltas.
"""
return self._per_block_requested(entry, lora_stack)
def _model_cache_key(self, model):
model_wrapper = getattr(model, "model", None)
@@ -857,6 +900,20 @@ class FunPackLoraLoader:
if cached is not None:
return cached
containers = {block_container_name(k) for k in loaded}
containers.discard(None)
if len(containers) > 1:
# Both containers number from 0, so their blocks would share buckets and one
# scale would land on two unrelated blocks. Declining is the honest answer; the
# caller falls back to global at the plain weight.
_log.note_on_change(
"lora:block_containers", "FunPack",
f"per-block declined: this model numbers its blocks in {len(containers)} "
f"separate stacks ({', '.join(sorted(containers))}), which start at 0 each, "
f"so a block index does not identify one block. The LoRA applies globally "
f"at its plain weight instead.")
return None
global_patches, block_patches = self._split_model_patches_by_block(loaded)
block_scores = self._block_scores_from_patches(block_patches)
base_scales = self._block_scales_from_scores(block_scores)
@@ -894,10 +951,11 @@ class FunPackLoraLoader:
result = dict(scales)
suppressed = 0
anchors = semantic_anchor_blocks(block_indices)
for block_index, scale in scales.items():
pos = block_index / max_idx
if bad and bad[0] <= pos <= bad[1]:
if block_index in SEMANTIC_ANCHOR_BLOCKS:
if block_index in anchors:
continue
result[block_index] = 0.0
suppressed += 1
@@ -909,9 +967,10 @@ class FunPackLoraLoader:
total = sum(block_scores.values())
if total <= 0.0:
return 1.0
anchor_energy = sum(block_scores.get(b, 0.0) for b in SEMANTIC_ANCHOR_BLOCKS)
anchors = semantic_anchor_blocks(block_scores.keys())
anchor_energy = sum(block_scores.get(b, 0.0) for b in anchors)
anchor_share = anchor_energy / total
expected = len(SEMANTIC_ANCHOR_BLOCKS) / max(len(block_scores), 1)
expected = len(anchors) / max(len(block_scores), 1)
ratio = anchor_share / max(expected, 1e-9)
return max(0.92, min(1.08, 1.0 + (ratio - 1.0) * 0.08))
@@ -968,6 +1027,7 @@ class FunPackLoraLoader:
return
block_indices = sorted({block_index for profile in profiles for block_index in profile["normalized_scores"]})
anchors = semantic_anchor_blocks(block_indices)
for block_index in block_indices:
contributors = [
profile
@@ -1003,7 +1063,7 @@ class FunPackLoraLoader:
advantage = (own_signal - strongest_other) / max(own_signal + strongest_other, 1e-9)
if advantage >= 0.0:
multiplier = 1.0 + min(0.18, advantage * 0.14) * min(1.0, overlap_ratio * 1.25)
elif block_index in SEMANTIC_ANCHOR_BLOCKS:
elif block_index in anchors:
continue
else:
pressure = min(1.0, overlap_ratio * 1.35)
+93
View File
@@ -0,0 +1,93 @@
"""Per-block LoRA application is driven by the adapter's deltas, not by the architecture.
It used to run only for image_model in {ltxv, ltxav} and only for keys named
`transformer_blocks.N.`, so every other model got the global fallback without anything ever
looking at its weights. MiniMax H3 names its stack `blocks.N.`, which the old pattern did
not match at all — per-block would have been a silent no-op there even with the gate open.
"""
import sys
import pytest
import torch
sys.path.insert(0, ".")
@pytest.fixture
def mm():
import model_management
return model_management
@pytest.mark.parametrize("key,expected", [
("diffusion_model.transformer_blocks.7.attn.to_q.weight", 7), # LTX
("diffusion_model.blocks.7.attn.qkv.weight", 7), # MiniMax H3
("diffusion_model.double_blocks.3.img_attn.qkv.weight", 3),
("diffusion_model.single_blocks.11.linear1.weight", 11),
("model.layers.4.self_attn.q_proj.weight", 4),
("diffusion_model.final_layer.weight", None),
])
def test_a_numbered_block_is_found_whatever_the_stack_is_called(mm, key, expected):
assert mm.transformer_block_index(key) == expected
def test_transformer_blocks_is_not_read_as_blocks(mm):
"""`transformer_blocks` must match as itself, not as a suffix of the generic name."""
assert mm.block_container_name(
"diffusion_model.transformer_blocks.2.ff.net.0.proj.weight") == "transformer_blocks"
def test_ltx_anchors_are_unchanged_on_a_28_block_model(mm):
"""PAG/STG's 14 and 19 were measured on LTX's 28 blocks. Expressing them as positions
must resolve back to exactly those two blocks, or this generalisation silently retunes
the model it was derived from."""
assert mm.semantic_anchor_blocks(range(28)) == frozenset({14, 19})
def test_anchors_scale_to_a_deeper_stack(mm):
"""A 48-block model gets the proportionally equivalent blocks, not 14 and 19."""
anchors = sorted(mm.semantic_anchor_blocks(range(48)))
assert anchors == [round(14 / 27 * 47), round(19 / 27 * 47)]
assert anchors != [14, 19]
def test_a_stack_too_short_to_compare_yields_what_it_has(mm):
assert mm.semantic_anchor_blocks([0]) == frozenset({0})
assert mm.semantic_anchor_blocks([]) == frozenset()
class _Entry(dict):
pass
def test_per_block_is_no_longer_gated_on_the_model(mm):
"""The gate is 'did the user ask for it'. Whether it can actually run is discovered
from the deltas afterwards."""
loader = mm.FunPackLoraLoader()
stack = {"mode": "minimax_h3", "per_block": True, "loras": []}
assert loader._per_block_supported(object(), stack, _Entry()) is True
assert loader._per_block_supported(object(), {"mode": "ltx2", "per_block": False},
_Entry()) is False
def _deltas(prefix, n, energy=1.0):
return {f"diffusion_model.{prefix}.{i}.attn.qkv.weight": torch.full((4, 4), energy * (i + 1))
for i in range(n)}
def test_an_h3_style_stack_gets_a_real_block_profile(mm):
"""The end of the change that matters: `blocks.N.` deltas now produce per-block scales
instead of falling through to global."""
loader = mm.FunPackLoraLoader()
template = loader._block_profile_template(_deltas("blocks", 8))
assert template is not None
assert template["block_count"] == 8
assert len(template["base_scales"]) == 8
def test_two_block_stacks_decline_rather_than_collide(mm, capsys):
"""double_blocks.0 and single_blocks.0 are different blocks with the same index."""
loader = mm.FunPackLoraLoader()
mixed = {**_deltas("double_blocks", 4), **_deltas("single_blocks", 4)}
assert loader._block_profile_template(mixed) is None
assert "per-block declined" in capsys.readouterr().out