Three live-run findings from the rental:
1. Block steering said "profile not ready (needs 2+ rated runs)" with 16
paired runs. The v1 liked/disliked-EMA attribution had no contrast when
ratings cluster on one pole (pole-vs-all-runs-mean cancels to zero) and
dropped rewards in (0, 0.3) entirely. Attribution is now the per-block
COVARIANCE between reward and activity over a rolling 32-run history:
every admissible reward contributes with its actual value, contrast
comes from reward variance, and the sampler log now states the real
blocker ("all rated alike - rate both good AND bad runs") instead of a
misleading run count. v1 profiles are discarded on first pairing.
2. output_guidance stayed "not ready (needs 10+)" forever on LTX-AV: the
end-of-run x0 snapshot was gated on output samples being a plain
torch.Tensor, but AV chain output is a NESTED tensor (video+audio) - so
the output value function never received a single sample. The snapshot
now extracts the video stream from nested outputs.
3. FunPackNVFP4ModelLoader hit a size-mismatch wall (9-vs-6 ada params,
connector dims): it loaded the state dict without the safetensors
METADATA that carries the LTX-AV architecture config, so detection
built the wrong model variant. Metadata now flows through, mirroring
comfy.sd.load_diffusion_model.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
228 lines
9.3 KiB
Python
228 lines
9.3 KiB
Python
"""Block Steering: recorder/steer hooks on a toy AV model + profile/score/gain math.
|
|
|
|
The toy model mirrors the LTX-AV calling convention the hooks rely on: blocks held in
|
|
`model.model.diffusion_model.transformer_blocks` (a ModuleList of >= 28 entries, which
|
|
is what _funpack_locate_blocks requires), each called as block((vx, ax), ...) and
|
|
returning (vx_out, ax_out). Persistence tests point the refinement store at a tmp dir.
|
|
"""
|
|
import sys
|
|
import types
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import torch
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
|
|
|
import block_steer as bs # noqa: E402
|
|
|
|
N_BLOCKS = 28 # _funpack_locate_blocks' minimum believable transformer depth
|
|
DIM = 8
|
|
|
|
|
|
class _ToyAVBlock(torch.nn.Module):
|
|
def __init__(self, v_gain, a_gain=0.5):
|
|
super().__init__()
|
|
self.v_gain = v_gain
|
|
self.a_gain = a_gain
|
|
|
|
def forward(self, x, **kwargs):
|
|
vx, ax = x
|
|
return vx + self.v_gain * torch.ones_like(vx), ax + self.a_gain * torch.ones_like(ax)
|
|
|
|
|
|
def _toy_model(v_gains):
|
|
diff = torch.nn.Module()
|
|
diff.transformer_blocks = torch.nn.ModuleList([_ToyAVBlock(g) for g in v_gains])
|
|
inner = types.SimpleNamespace(diffusion_model=diff)
|
|
return types.SimpleNamespace(model=inner, model_options={})
|
|
|
|
|
|
def _run(model, vx=None, ax=None):
|
|
vx = vx if vx is not None else torch.ones(1, 4, DIM)
|
|
ax = ax if ax is not None else torch.ones(1, 2, DIM)
|
|
for blk in model.model.diffusion_model.transformer_blocks:
|
|
vx, ax = blk((vx, ax))
|
|
return vx, ax
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path, monkeypatch):
|
|
"""Redirect refinement sidecars into a tmp dir (no conditioning.py import needed)."""
|
|
def _path(key, name, prefix="refine_v2", extension="json"):
|
|
return str(tmp_path / f"{key}.{name}.{extension}")
|
|
monkeypatch.setattr(bs, "_state_path", lambda key, name: _path(key, name, extension="pt"))
|
|
return tmp_path
|
|
|
|
|
|
def test_recorder_ranks_blocks_by_video_activity():
|
|
gains = [0.01] * N_BLOCKS
|
|
gains[5], gains[20] = 2.0, 1.0 # blocks 5 and 20 move the video stream hardest
|
|
model = _toy_model(gains)
|
|
rec = bs.BlockActivityRecorder(N_BLOCKS)
|
|
handles = bs.install_recorder(model, rec)
|
|
assert len(handles) == N_BLOCKS
|
|
_run(model)
|
|
_run(model)
|
|
bs.remove_handles(handles)
|
|
fp = rec.fingerprint()
|
|
assert fp is not None and int(rec.counts[0]) == 2
|
|
assert int(fp.argmax()) == 5
|
|
assert fp[20] > fp[3]
|
|
# hooks removed - a third run must not record
|
|
_run(model)
|
|
assert int(rec.counts[0]) == 2
|
|
|
|
|
|
def test_hooks_carry_leak_strip_tag():
|
|
"""Recorder AND steer hooks must be tagged so the sampler's defensive
|
|
strip_funpack_block_hooks cleans them if a run crashes mid-sample."""
|
|
model = _toy_model([1.0] * N_BLOCKS)
|
|
rec = bs.BlockActivityRecorder(N_BLOCKS)
|
|
h1 = bs.install_recorder(model, rec)
|
|
h2 = bs.install_steer(model, [1.05] * N_BLOCKS)
|
|
try:
|
|
for blk in model.model.diffusion_model.transformer_blocks:
|
|
fns = list(blk._forward_hooks.values())
|
|
assert fns and all(getattr(f, bs._FUNPACK_HOOK_TAG, False) for f in fns)
|
|
finally:
|
|
bs.remove_handles(h1 + h2)
|
|
|
|
|
|
def test_steer_scales_video_residual_only():
|
|
model = _toy_model([1.0] * N_BLOCKS)
|
|
v_ref, a_ref = _run(model)
|
|
gains = [1.0] * N_BLOCKS
|
|
gains[3] = 1.10 # boost block 3's video contribution by 10%
|
|
gains[7] = 0.90 # damp block 7's
|
|
handles = bs.install_steer(model, gains)
|
|
assert len(handles) == 2 # gain==1.0 blocks get NO hook (native path)
|
|
v_out, a_out = _run(model)
|
|
bs.remove_handles(handles)
|
|
# video: each block adds +1; two blocks changed by +/-0.1 -> net exactly equal
|
|
assert torch.allclose(v_out, v_ref, atol=1e-5) # +0.1 and -0.1 cancel
|
|
gains[7] = 1.0
|
|
handles = bs.install_steer(model, gains)
|
|
v_out2, _ = _run(model)
|
|
bs.remove_handles(handles)
|
|
assert torch.allclose(v_out2 - v_ref, torch.full_like(v_out2, 0.10), atol=1e-5)
|
|
# audio stream byte-identical under steering
|
|
assert torch.equal(a_out, a_ref)
|
|
|
|
|
|
def test_credit_wrapper_scores_and_credits():
|
|
model = _toy_model([1.0] * N_BLOCKS)
|
|
rec = bs.BlockActivityRecorder(N_BLOCKS)
|
|
handles = bs.install_recorder(model, rec)
|
|
|
|
class _VF(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.scores = iter([0.1, 0.5, 0.2])
|
|
def compress(self, x):
|
|
return x.mean().unsqueeze(0)
|
|
def forward(self, c):
|
|
return torch.tensor([next(self.scores)])
|
|
|
|
def apply_fn(x, t, **c):
|
|
_run(model) # blocks fire -> recorder.cur populated
|
|
return x * 0.9
|
|
|
|
wrapper = bs.make_credit_wrapper(None, rec, _VF())
|
|
args = {"input": torch.ones(1, 4, DIM), "timestep": torch.tensor([0.9]), "c": {}}
|
|
for _ in range(3):
|
|
wrapper(apply_fn, args)
|
|
bs.remove_handles(handles)
|
|
assert rec.credit_steps == 2 # first call only sets the baseline
|
|
cv = rec.credit_vector()
|
|
assert cv is not None and cv.shape == (N_BLOCKS,)
|
|
# In the toy model vx grows by +1 per block, so relative deltas DECREASE with depth:
|
|
# early blocks are the most active. Net score delta across the run is positive
|
|
# (0.1 -> 0.5 -> 0.2), so credit must follow the centered activity gradient -
|
|
# positive for the most active (early) blocks, negative for the least active (late).
|
|
assert float(cv[0]) > 0 > float(cv[-1])
|
|
assert int(cv.argmax()) == 0
|
|
|
|
|
|
def _rec_with(activity):
|
|
r = bs.BlockActivityRecorder(activity.numel())
|
|
r.sums = activity.clone().float(); r.counts = torch.ones(activity.numel())
|
|
return r
|
|
|
|
|
|
def test_snapshot_profile_scores_gains_roundtrip(store):
|
|
key = "toykey"
|
|
base = torch.full((N_BLOCKS,), 0.5)
|
|
hot = base.clone(); hot[10] = 1.5 # block 10 fires hard on the GOOD run
|
|
cold = base.clone(); cold[2] = 1.5 # block 2 fires hard on the BAD run
|
|
|
|
assert bs.save_run_snapshot(key, _rec_with(hot))
|
|
assert bs.update_profile_with_rating(key, 1.0) == 1 # Perfect
|
|
assert bs.update_profile_with_rating(key, 1.0) is None # snapshot consumed - no double pair
|
|
scores, status = bs.block_scores_with_status(key)
|
|
assert scores is None and "have 1" in status # 1 rated run < MIN_RATED_RUNS
|
|
|
|
assert bs.save_run_snapshot(key, _rec_with(cold))
|
|
assert bs.update_profile_with_rating(key, -0.9) == 2 # Awful
|
|
scores, status = bs.block_scores_with_status(key)
|
|
assert status == "ready"
|
|
assert scores is not None and scores.shape == (N_BLOCKS,)
|
|
assert int(scores.argmax()) == 10 and int(scores.argmin()) == 2
|
|
assert abs(float(scores.mean())) < 1e-5 # zero-mean: redistributes, no drift
|
|
assert float(scores.abs().max()) == pytest.approx(1.0, abs=1e-5)
|
|
|
|
gains = bs.gains_from_scores(scores, 0.05)
|
|
assert max(gains) == pytest.approx(1.0 + 0.05, abs=1e-4)
|
|
assert min(gains) == pytest.approx(1.0 - 0.05, abs=1e-4)
|
|
# hard cap regardless of strength widget
|
|
gains_hi = bs.gains_from_scores(scores, 5.0)
|
|
assert max(gains_hi) <= 1.0 + bs.MAX_GAIN_DELTA + 1e-6
|
|
assert min(gains_hi) >= 1.0 - bs.MAX_GAIN_DELTA - 1e-6
|
|
|
|
|
|
def test_same_pole_ratings_need_contrast_not_more_runs(store):
|
|
"""The v1 regression: MANY rated runs, all rated alike -> the profile must say WHY
|
|
it can't steer (no reward contrast), not pretend it needs more runs."""
|
|
key = "samepole"
|
|
for _ in range(16):
|
|
assert bs.save_run_snapshot(key, _rec_with(torch.rand(N_BLOCKS)))
|
|
assert bs.update_profile_with_rating(key, 1.0) is not None
|
|
scores, status = bs.block_scores_with_status(key)
|
|
assert scores is None
|
|
assert "rated alike" in status and "16" in status
|
|
# one contrasting rating unlocks attribution
|
|
assert bs.save_run_snapshot(key, _rec_with(torch.rand(N_BLOCKS)))
|
|
bs.update_profile_with_rating(key, -0.9)
|
|
scores, status = bs.block_scores_with_status(key)
|
|
assert scores is not None and status == "ready"
|
|
|
|
|
|
def test_every_reward_value_contributes(store):
|
|
"""No dead band: mild rewards like Missing action (0.05) / Wrong details (0.20)
|
|
count with their actual value instead of being dropped."""
|
|
key = "mild"
|
|
base = torch.full((N_BLOCKS,), 0.5)
|
|
hi = base.clone(); hi[7] = 1.2
|
|
lo = base.clone(); lo[21] = 1.2
|
|
bs.save_run_snapshot(key, _rec_with(hi)); bs.update_profile_with_rating(key, 0.35)
|
|
bs.save_run_snapshot(key, _rec_with(lo)); bs.update_profile_with_rating(key, 0.05)
|
|
scores, status = bs.block_scores_with_status(key)
|
|
assert status == "ready"
|
|
assert int(scores.argmax()) == 7 and int(scores.argmin()) == 21
|
|
|
|
|
|
def test_depth_mismatch_and_v1_profile_reset(store):
|
|
key = "depth"
|
|
bs.save_run_snapshot(key, _rec_with(torch.rand(N_BLOCKS)))
|
|
bs.update_profile_with_rating(key, 1.0)
|
|
r2 = torch.rand(N_BLOCKS + 4) # different model depth
|
|
bs.save_run_snapshot(key, _rec_with(r2))
|
|
assert bs.update_profile_with_rating(key, 1.0) == 1 # fresh profile, not a mixed one
|
|
prof = torch.load(bs._state_path(key, "block_profile"), weights_only=False)
|
|
assert prof["n_blocks"] == N_BLOCKS + 4
|
|
# a v1 pole-EMA profile on disk (liked_n, no history) is discarded, not mixed in
|
|
torch.save({"n_blocks": N_BLOCKS, "liked": torch.rand(N_BLOCKS), "liked_n": 16},
|
|
bs._state_path(key, "block_profile"))
|
|
bs.save_run_snapshot(key, _rec_with(torch.rand(N_BLOCKS)))
|
|
assert bs.update_profile_with_rating(key, 1.0) == 1
|