Region lock: a keyframe pin can cover part of a frame. Transparent areas of an i2v anchor are dropped from the packed sequence entirely — not blanked, not noised, absent — so the model invents them. Row deletion re-derives the layout from upstream's own output; two patches share one keep-mask so they cannot disagree. Surfaced through the anchor's alpha, which needs no new wire and works in the Editor. Excluded pixels are filled with the kept area's mean before the VAE so no hard edge bleeds in. EXPERIMENTAL: fl2va saw whole condition frames. Taste bias: the liked direction Studio already learns is now also added AFTER H3's token refiner, where it lands as sent instead of being mixed across tokens and normalized away. Relative to the span's own row norm, so one strength means the same thing on every prompt. How far to push is a fifth learned gain, centred on 0 and signed; h3_taste_bias is the manual override. Needs 3 liked runs. Gain state generalized to per-key centres, bounds and exploration width. Tests: 1608 passed (+46). Untested on a rental.
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""Editing the token refiner's OUTPUT rather than the Qwen tensor before it.
|
|
|
|
H3 takes `context` [1, L, 5120] through condition_proj (-> 5376) and a 2-block token
|
|
refiner that ends in an RMSNorm. Everything FunPack steers today happens BEFORE that, so
|
|
the refiner processes the edit: its attention mixes it across tokens and its final norm
|
|
renormalizes the magnitude away. An edit applied AFTER lands as set, in the space the 50
|
|
blocks consume.
|
|
|
|
Two edits: `scale` (how loudly the prompt is read through attention) and `bias` (a single
|
|
[hidden] vector added to every prompt row — prompt-LENGTH-INDEPENDENT, unlike anything
|
|
per-position, because Qwen does not pad and H3's sequence length moves with every edit).
|
|
"""
|
|
import inspect
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
import minimax_h3 as h3
|
|
|
|
HID = 6
|
|
|
|
|
|
class FakeRefiner(torch.nn.Module):
|
|
def forward(self, x, *a, **k):
|
|
return torch.ones(x.shape[-2], HID)
|
|
|
|
|
|
class FakePatcher:
|
|
def __init__(self, proj_weight=None):
|
|
self.objects = {"diffusion_model.token_refiner": FakeRefiner()}
|
|
if proj_weight is not None:
|
|
self.objects["diffusion_model.condition_proj"] = \
|
|
torch.nn.Linear(proj_weight.shape[1], proj_weight.shape[0])
|
|
with torch.no_grad():
|
|
self.objects["diffusion_model.condition_proj"].weight.copy_(proj_weight)
|
|
self.patched = {}
|
|
|
|
def get_model_object(self, name):
|
|
if name in self.patched:
|
|
return self.patched[name]
|
|
if name not in self.objects:
|
|
raise KeyError(name)
|
|
return self.objects[name]
|
|
|
|
def clone(self):
|
|
other = FakePatcher.__new__(FakePatcher)
|
|
other.objects, other.patched = self.objects, dict(self.patched)
|
|
return other
|
|
|
|
def add_object_patch(self, name, obj):
|
|
self.patched[name] = obj
|
|
|
|
|
|
def run(edit, rows=8):
|
|
return edit(torch.zeros(1, rows, 4))
|
|
|
|
|
|
# ── the edit ────────────────────────────────────────────────────────────────
|
|
|
|
def test_scale_multiplies_the_rows():
|
|
out = run(h3.TokenRefinerEdit(FakeRefiner(), scale=0.5))
|
|
assert torch.allclose(out, torch.full((8, HID), 0.5))
|
|
|
|
|
|
def test_rows_before_the_start_are_untouched():
|
|
"""The <Picture N> label and vision block sit at the head of this span."""
|
|
out = run(h3.TokenRefinerEdit(FakeRefiner(), scale=0.0, row_start=3))
|
|
assert torch.all(out[:3] == 1.0)
|
|
assert torch.all(out[3:] == 0.0)
|
|
|
|
|
|
def test_a_row_end_bounds_the_other_side():
|
|
out = run(h3.TokenRefinerEdit(FakeRefiner(), scale=2.0, row_start=2, row_end=5))
|
|
assert torch.all(out[:2] == 1.0) and torch.all(out[5:] == 1.0)
|
|
assert torch.all(out[2:5] == 2.0)
|
|
|
|
|
|
def test_a_bias_is_added_to_every_row_in_range():
|
|
bias = torch.arange(HID, dtype=torch.float32)
|
|
out = run(h3.TokenRefinerEdit(FakeRefiner(), bias=bias, row_start=1))
|
|
assert torch.allclose(out[1], 1.0 + bias)
|
|
assert torch.allclose(out[5], 1.0 + bias) # same vector on every row
|
|
assert torch.all(out[0] == 1.0)
|
|
|
|
|
|
def test_the_bias_is_length_independent():
|
|
"""The property that makes it a usable learning target: one vector, any prompt length."""
|
|
bias = torch.full((HID,), 0.25)
|
|
short = run(h3.TokenRefinerEdit(FakeRefiner(), bias=bias), rows=4)
|
|
long = run(h3.TokenRefinerEdit(FakeRefiner(), bias=bias), rows=40)
|
|
assert torch.allclose(short[0], long[0])
|
|
|
|
|
|
def test_scale_and_bias_compose_scale_first():
|
|
bias = torch.full((HID,), 1.0)
|
|
out = run(h3.TokenRefinerEdit(FakeRefiner(), scale=2.0, bias=bias))
|
|
assert torch.allclose(out, torch.full((8, HID), 3.0)) # 1*2 + 1
|
|
|
|
|
|
def test_an_out_of_range_start_is_a_no_op_not_a_crash():
|
|
out = run(h3.TokenRefinerEdit(FakeRefiner(), scale=0.0, row_start=99))
|
|
assert torch.all(out == 1.0)
|
|
|
|
|
|
def test_the_inner_output_is_not_mutated_in_place():
|
|
inner = FakeRefiner()
|
|
edit = h3.TokenRefinerEdit(inner, scale=0.0)
|
|
run(edit)
|
|
assert torch.all(inner(torch.zeros(1, 8, 4)) == 1.0)
|
|
|
|
|
|
# ── attaching it ────────────────────────────────────────────────────────────
|
|
|
|
def test_no_edit_returns_the_model_unpatched():
|
|
model = FakePatcher()
|
|
out, note = h3.apply_token_refiner_edit(model, scale=1.0, bias=None)
|
|
assert out is model and note is None and model.patched == {}
|
|
|
|
|
|
def test_it_patches_the_refiner_object():
|
|
model = FakePatcher()
|
|
out, note = h3.apply_token_refiner_edit(model, scale=1.2, row_start=4)
|
|
assert isinstance(out.patched["diffusion_model.token_refiner"], h3.TokenRefinerEdit)
|
|
assert "scale=1.2" in note and "rows 4:end" in note
|
|
|
|
|
|
def test_object_patches_so_nothing_outlives_the_run():
|
|
src = inspect.getsource(h3.apply_token_refiner_edit)
|
|
assert "add_object_patch" in src and "model.clone()" in src
|
|
|
|
|
|
def test_a_model_without_a_refiner_declines_with_a_reason():
|
|
class Bare:
|
|
def get_model_object(self, name):
|
|
raise KeyError(name)
|
|
out, note = h3.apply_token_refiner_edit(Bare(), scale=0.5)
|
|
assert isinstance(out, Bare) and "no token_refiner" in note
|
|
|
|
|
|
# ── projecting a conditioning-space direction ───────────────────────────────
|
|
|
|
def test_a_direction_is_projected_by_the_weight_only():
|
|
"""A direction is a difference of two points, so the bias would move the origin."""
|
|
weight = torch.randn(HID, 4)
|
|
model = FakePatcher(proj_weight=weight)
|
|
direction = torch.randn(4)
|
|
out = h3.project_into_refiner_space(model, direction)
|
|
assert torch.allclose(out, weight @ direction, atol=1e-5)
|
|
|
|
|
|
def test_a_mismatched_width_is_refused():
|
|
model = FakePatcher(proj_weight=torch.randn(HID, 4))
|
|
assert h3.project_into_refiner_space(model, torch.randn(9)) is None
|
|
|
|
|
|
def test_no_projection_available_returns_none():
|
|
assert h3.project_into_refiner_space(FakePatcher(), torch.randn(4)) is None
|
|
|
|
|
|
# ── independence from the refinement path ───────────────────────────────────
|
|
|
|
def test_the_installer_never_reaches_past_the_conditioning():
|
|
"""The refiner edit is now partly rating-learned, but the SAMPLER still only applies.
|
|
|
|
Studio owns the refinement key, the state file and the learning; the sampler reads what
|
|
Studio tagged onto the conditioning and nothing else. Reaching for the key here would put
|
|
learning on both sides of the bridge, which is the boundary this project keeps.
|
|
"""
|
|
import samplers
|
|
cls = samplers.FunPackLTXAVSceneChainSampler
|
|
src = "".join(inspect.getsource(fn) for fn in
|
|
(cls._install_h3_token_refiner, cls._h3_taste_bias_vector, cls._h3_prompt_rows))
|
|
for forbidden in ("refinement_key", "_v2_load_state", "value_fn", "global_state",
|
|
"phrase_memory"):
|
|
assert forbidden not in src
|
|
|
|
|
|
def test_the_widget_defaults_to_untouched():
|
|
import samplers
|
|
spec = samplers.FunPackLTXAVSceneChainSampler.INPUT_TYPES()
|
|
fields = {**spec.get("required", {}), **spec.get("optional", {})}
|
|
assert fields["h3_prompt_scale"][1]["default"] == 1.0
|
|
assert "h3_gain_prompt" in fields["h3_prompt_scale"][1]["tooltip"] # says how they differ
|
|
|
|
|
|
# ── the learned taste bias ──────────────────────────────────────────────────
|
|
#
|
|
# The direction comes from ratings (Studio's `liked_dir`), the magnitude does not. A learned
|
|
# direction has no natural scale in the refiner's space — those norms belong to the
|
|
# checkpoint — so the bias is a UNIT vector rescaled at apply time to a fraction of the
|
|
# span's own mean row norm. "10% of a typical prompt row" means the same thing on every
|
|
# prompt and every checkpoint; an absolute vector would be inert or catastrophic with no way
|
|
# to tell which in advance.
|
|
|
|
class ScaledRefiner(torch.nn.Module):
|
|
"""Rows with a known norm, so the relative rescale can be checked exactly."""
|
|
|
|
def __init__(self, value):
|
|
super().__init__()
|
|
self.value = float(value)
|
|
|
|
def forward(self, x, *a, **k):
|
|
return torch.full((x.shape[-2], HID), self.value)
|
|
|
|
|
|
def test_a_relative_bias_scales_to_the_rows_it_lands_on():
|
|
unit = torch.zeros(HID)
|
|
unit[0] = 1.0
|
|
out = h3.TokenRefinerEdit(ScaledRefiner(2.0), bias=unit * 0.5,
|
|
bias_relative=True)(torch.zeros(1, 4, 4))
|
|
row_norm = (torch.full((HID,), 2.0)).norm() # every row has this norm
|
|
assert out[0, 0] == pytest.approx(2.0 + 0.5 * float(row_norm), rel=1e-5)
|
|
assert out[0, 1] == pytest.approx(2.0) # off-direction untouched
|
|
|
|
|
|
def test_the_same_strength_means_the_same_thing_at_a_different_scale():
|
|
"""The property the relative mode exists for: transferable between checkpoints."""
|
|
unit = torch.zeros(HID)
|
|
unit[0] = 1.0
|
|
small = h3.TokenRefinerEdit(ScaledRefiner(1.0), bias=unit * 0.25,
|
|
bias_relative=True)(torch.zeros(1, 4, 4))
|
|
big = h3.TokenRefinerEdit(ScaledRefiner(100.0), bias=unit * 0.25,
|
|
bias_relative=True)(torch.zeros(1, 4, 4))
|
|
assert (small[0, 0] / 1.0) == pytest.approx(big[0, 0] / 100.0, rel=1e-4)
|
|
|
|
|
|
def test_an_absolute_bias_still_ignores_the_row_scale():
|
|
unit = torch.zeros(HID)
|
|
unit[0] = 1.0
|
|
out = h3.TokenRefinerEdit(ScaledRefiner(5.0), bias=unit * 0.5)(torch.zeros(1, 4, 4))
|
|
assert out[0, 0] == pytest.approx(5.5)
|
|
|
|
|
|
def test_a_negative_strength_pushes_the_other_way():
|
|
"""Away from what was liked has to be reachable, or the loop can only ever agree."""
|
|
unit = torch.zeros(HID)
|
|
unit[0] = 1.0
|
|
out = h3.TokenRefinerEdit(ScaledRefiner(2.0), bias=unit * -0.5,
|
|
bias_relative=True)(torch.zeros(1, 4, 4))
|
|
assert out[0, 0] < 2.0
|
|
|
|
|
|
def test_the_note_says_the_bias_is_relative():
|
|
model = FakePatcher()
|
|
_out, note = h3.apply_token_refiner_edit(model, bias=torch.zeros(HID), bias_relative=True)
|
|
assert "bias (relative)" in note
|