Files
olivv-cs-ComfyUI-FunPack/h3_repr_steering.py
T
digital-garbageandClaude Opus 5.5 fa04d6c200 fix: H3 enhancements that crashed, steered the wrong rows, or did nothing
- region lock: layout patch matched the old PackedLayout signature and crashed every H3 run; interior pins now use upstream's native any-frame placement
- REINS/Q-steer/AV decouple/probes: masks cover the target video/audio rows only, not pins, refs or vision pads
- video detail: works with the PDD FinalLayer signature; now in Engine Settings
- Q-steer: steers queries before RoPE, not after (old banks are not reused)
- ALG anchor: blurs the frame-0 pin on H3
- temporal style: hidden on H3
- shadow negative: main-window chip names what it switches off without compose

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:51:03 +03:00

432 lines
21 KiB
Python

"""REINS-style representation steering for MiniMax H3.
Everything else this project has built for ratings acts OUTSIDE the model: an attention
bias on the prompt's own tokens (h3_token_weights), or a push on the latent between denoiser
calls (output_guidance, trajectory_guidance). Neither changes what the model itself computes.
This does. It captures the video rows of one block's hidden state per generation, keeps a
running direction from how each generation was rated, and adds that direction back into the
same block's output on the next run -- reaching inside the forward pass instead of around it.
Training-free: no gradient, no MLP, a mean difference of vectors is the whole "model".
("Pulling The REINS", arXiv:2606.17257 -- same idea, applied here via a two-group mean
difference instead of their supervised-PCA.)
Liked vs disliked by the rating's SIGN only, not weighted by magnitude. A magnitude-weighted
version (a 9/10 pulling harder than a 6/10) was tried first and is the statistically better
estimator in the limit -- but it needs enough data for a weakly-rated row's noise to average
out, and at the handful of ratings a key actually has, a mediocre "6" just gets a vote and
dilutes the direction instead of being excluded by it. Binary spends what little data exists
on the clearest examples only; see direction()'s docstring and the 2026-09-04 session before
reintroducing weighting. The weight is still the rating's own `reward` -- already
admissibility-filtered by the caller (conditioning.py only commits inside the same
`_v2_reward_admissible` gate the value functions use), so a Wrong-* identity-mismatch rating
never reaches here at all, not because its number was excluded but because it was never a
quality verdict to begin with.
Masked to VIDEO rows only, using the model's own `mod_segments` tags (0=video, 1=text,
2=audio) -- never audio or text rows. The one documented failure of this class of
intervention (`project_self_consistency_failed.md`) happened on a DIFFERENT model (LTXAV)
by overwriting rows fed back through joint audio-video attention; H3 has no separate
audio-attention path to spare, so touching audio rows here risks the same corruption for a
worse reason. This is the mitigation, not a guarantee -- unvalidated until it's actually run.
"""
from __future__ import annotations
import os
import torch
# Roughly half of H3's 50 blocks -- REINS reports this FRACTION as their sweet spot (their
# own notation: block l in {0..L-1}, effect peaking ~50% of L), on whatever model they tested
# -- not H3, and their own paper picks the actual best layer per model by sweep rather than
# trusting 50% universally. This is that same sweep, run on H3 with real rated data instead
# of guessed from someone else's percentage. Only DEFAULT_BLOCK ever STEERS; the rest of
# CANDIDATE_BLOCKS are captured read-only alongside it so block_sweep() has something to rank.
DEFAULT_BLOCK = 25
# Every block, 0-49: the coarse grid + 41-49 individually was a compromise when only the
# tail's back-loaded weight was known to be worth resolving finely (48+49 alone carrying ~a
# third of all residual movement). 2026-09-04: block 0/1 turned out to be structurally
# distinct too (raw input projection / patch-mixing), not just quiet early blocks -- so
# "coarse in the middle" was an assumption, not a finding. Capturing every block read-only
# costs one extra mean-pool per block per run; steering still only ever touches one block at
# a time (whichever `steer_block` names).
CANDIDATE_BLOCKS = list(range(50))
# Each rating's vote shrinks by this factor for every rating that came after it (read-time,
# nothing stored changes). Same constant as Studio's V2_PATH_OUTCOME_DECAY so the two forget
# at the same speed: half the vote sits in the last ~7 ratings. Without it a plain mean let
# twenty early ratings of one style outvote the new style until it had been out-rated
# one-for-one (the "first videos stick" observation, 2026-09-07).
RECENCY_DECAY = 0.9
MIN_PER_GROUP = 2 # need 2+ POSITIVE-weight and 2+ NEGATIVE-weight rows. Was 3 (parity with
# absolute/taste steering's floor, not derived from anything specific to this mechanism).
# 1 would mean the "direction" is literally one liked descriptor minus one disliked one --
# no averaging, as vulnerable to a single noisy capture as anything else this project has
# learned not to trust from one data point. 2 still averages a pair on each side while
# halving the wait (4 rated runs instead of 6) -- lower this only if you want to see raw
# per-pair noise, not a real direction. A weight near zero still gets logged and still
# contributes, just barely -- it does not count toward this floor.
def _log():
try:
from . import funpack_log as fl
except ImportError:
import funpack_log as fl
return fl
def state_path(refinement_key, kind="repr_steer"):
try:
from .conditioning import refinement_state_path
except ImportError:
from conditioning import refinement_state_path
return refinement_state_path(refinement_key, kind, prefix="refine_v2", extension="pt")
def _mask_from_mod_segments(mod_segments, seq_len, device, tag):
"""Shared by video_mask_from_mod_segments/audio_mask_from_mod_segments.
The TARGET streams only: upstream packs ``[text | cond/ref | audio | video]`` and emits
one mod_segments entry per target stream, so target video is the last entry and target
audio the one before it. Matching by modality tag alone is wrong — tag 0 also marks the
i2v anchor pin, guide keyframes, reference images/videos and the vision tokens inside the
text span, none of which a "video rows" feature may steer or learn from.
Returns None (not an all-False mask) when the layout cannot be proven — wrong tag at that
position, or a sequence it does not fit (the 2-block token refiner's text-only calls) —
so callers no-op rather than guess.
"""
segs = list(mod_segments or ())
if len(segs) < 2 or tag not in (0, 2):
return None
a, b, row = segs[-1] if tag == 0 else segs[-2]
try:
ok = bool(((row % 3) == tag).all()) if torch.is_tensor(row) else int(row) % 3 == tag
except Exception: # noqa: BLE001
return None
if not ok or not (0 <= a < b <= seq_len):
return None
mask = torch.zeros(seq_len, dtype=torch.bool, device=device)
mask[a:b] = True
return mask
def video_mask_from_mod_segments(mod_segments, seq_len, device):
"""mod_segments (from the H3 block hook's own args) -> a [seq_len] bool mask, True on the
TARGET video rows (the last segment). None when that cannot be proven."""
return _mask_from_mod_segments(mod_segments, seq_len, device, 0)
def audio_mask_from_mod_segments(mod_segments, seq_len, device):
"""Same as video_mask_from_mod_segments but for the TARGET audio rows (second to last)."""
return _mask_from_mod_segments(mod_segments, seq_len, device, 2)
#: Query steering's store. Its rows are captured BEFORE H3's rotary position encoding; the
#: old "q_steer" rows were taken after it, mean something else, and must not mix with these.
Q_STEER_KIND = "q_steer_prerope"
def rope_table_rows(rope_freqs, seq_len):
"""H3's per-row rotation table ``[1, S, 1, half, 2, 2]`` (the block hook's ``rope_freqs``)
-> ``[S, half, 2, 2]``, or None when it is not that shape for this sequence."""
if not torch.is_tensor(rope_freqs) or rope_freqs.ndim != 6 \
or rope_freqs.shape[1] != seq_len or rope_freqs.shape[-2:] != (2, 2):
return None
return rope_freqs[0, :, 0]
def rotate_rows(x, table, inverse=False):
"""Apply (or undo) H3's split-half rotary encoding to ``x`` ``[..., n, D]``.
Mirrors comfy_kitchen's ``apply_rope_split_half1`` on the first ``2 * half`` dims — dim
``i`` pairs with ``i + half``, each pair turned by its row's 2x2 matrix — and passes the
rest through. The inverse is the transpose, so undoing it is exact.
"""
half = int(table.shape[-3])
rot = 2 * half
if x.shape[-1] < rot:
raise ValueError(f"head dim {x.shape[-1]} < rotary dim {rot}")
pairs = x[..., :rot].reshape(*x.shape[:-1], 2, half).movedim(-2, -1).float()
mat = table.float()
if inverse:
mat = mat.transpose(-1, -2)
out = (mat @ pairs.unsqueeze(-1)).squeeze(-1)
out = out.movedim(-1, -2).reshape(*x.shape[:-1], rot).to(x.dtype)
return torch.cat([out, x[..., rot:]], dim=-1)
def capture(hidden_state, video_mask, has_rows=None):
"""[seq_len, hidden] + a video-row mask -> a [hidden] descriptor, or None.
Mean over rows, nothing more -- hidden_size is architecturally fixed (H3 is one width
throughout), so there is no varying dimension to pool away the way a latent's spatial
size needs adaptive_avg_pool1d for.
`has_rows`: pass True/False when the caller already knows (from a mask it cached and
already ran .any() on ONCE per seq_len, e.g. h3_repr_steering's/h3_q_steering's own
hooks) whether `video_mask` has at least one True row -- this skips re-deriving it here
with a SECOND host sync (bool() of a CUDA tensor) on every one of the 50 candidate
blocks, every step, which is exactly the kind of stall this hot path needs to avoid (see
project_reward_model_rework memory: implicated in a silent segfault under comfy_aimdo's
dynamic-VRAM block prefetch). Leave it None (the default, used by direct/test callers
with no cache of their own) to get the fully self-contained, host-synced check."""
if video_mask is None:
return None
if has_rows is None:
has_rows = bool(video_mask.any())
if not has_rows:
return None
return hidden_state[video_mask].detach().float().mean(dim=0)
# --- persistence -------------------------------------------------------------------------
#
# One row per generation: {"desc": {block: tensor}, "weight": float}.
# Kept as a log and the direction recomputed from it on demand -- same reasoning
# trajectory_guidance's train_from_rows gives: this is cheap enough that being able to
# re-derive it (a log carried from another box, a fixed weighting formula) is worth more than
# the saved cycles of an online update. `desc` captures EVERY candidate block, not just the
# one that steers, so block_sweep() can be answered from data already being collected instead
# of needing its own separate collection pass.
def _load(refinement_key, kind="repr_steer"):
path = state_path(refinement_key, kind)
if not os.path.exists(path):
return {"rows": [], "pending": None, "slots": {}}
try:
data = torch.load(path, map_location="cpu", weights_only=True)
except Exception: # noqa: BLE001
return {"rows": [], "pending": None, "slots": {}}
if not isinstance(data, dict):
return {"rows": [], "pending": None, "slots": {}}
data.setdefault("rows", [])
data.setdefault("pending", None)
data.setdefault("slots", {})
return data
def _save(refinement_key, data, kind="repr_steer"):
path = state_path(refinement_key, kind)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
torch.save(data, tmp)
os.replace(tmp, path)
def save_pending(refinement_key, descriptors, kind="repr_steer"):
"""Written on EVERY captured run, win or lose -- same fix trajectory_probe needed: a
pending capture must not survive to be paired with the NEXT run's rating instead of its
own, and the only way to guarantee that is to overwrite it every time, not just when a
rating happens to follow. `descriptors` is {block: tensor}, whatever candidate blocks the
sampler actually captured this run -- not required to cover every entry in
CANDIDATE_BLOCKS (a shape the hook has never seen just means that block is missing).
`kind` picks which sidecar file this is read/written under (see state_path) -- a second
mechanism capturing a DIFFERENT vector space at the same block index (h3_q_steering
captures Q, not hidden state) uses its own kind so the two never share rows under the
same block key."""
if not refinement_key or not descriptors:
return
data = _load(refinement_key, kind)
data["pending"] = {int(b): d.detach().float().cpu() for b, d in descriptors.items()}
try:
_save(refinement_key, data, kind)
except OSError as e:
_log().failed("H3 representation steering", "save pending", e,
"this run's capture is not kept")
def commit(refinement_key, reward, kind="repr_steer"):
"""Pairs the pending capture with a rating's WEIGHT and appends it to the log. `reward`
is the caller's already-resolved, already-admissibility-filtered reward -- this function
trusts it rather than re-deriving a verdict, same as the value functions do with the same
number.
-> "recorded" | "no_pending" (nothing generated since the last rating) | "no_key". The
caller prints this, not just the reward it computed -- a reward can be correct and still
never reach a row if there was nothing to pair it with, and a log line that only ever
reports the reward it is ABOUT to commit cannot tell that apart from a real commit."""
if not refinement_key:
return "no_key"
data = _load(refinement_key, kind)
pending = data.get("pending")
data["pending"] = None
if pending is None:
try:
_save(refinement_key, data, kind)
except OSError:
pass
return "no_pending"
try:
data["rows"].append({"desc": pending, "weight": float(reward)})
except (TypeError, ValueError):
try:
_save(refinement_key, data, kind)
except OSError:
pass
return "no_pending"
try:
_save(refinement_key, data, kind)
except OSError as e:
_log().failed("H3 representation steering", "commit", e, "rating not recorded")
return "no_pending"
return "recorded"
def save_capture_slot(refinement_key, slot_id, descriptors):
"""Like save_pending, but keyed by an explicit slot instead of the single overwritable
`pending` -- for a batch of generations that each need to stay independently rateable, in
any order, any subset, instead of each new run silently evicting the last one waiting to
be rated. `pending` is untouched by this (a slotted run does not also become "the"
pending capture), so the ordinary one-at-a-time rating flow keeps working exactly as
before for anything that doesn't pass a slot id."""
if not refinement_key or not slot_id or not descriptors:
return
data = _load(refinement_key)
data["slots"][str(slot_id)] = {int(b): d.detach().float().cpu() for b, d in descriptors.items()}
try:
_save(refinement_key, data)
except OSError as e:
_log().failed("H3 representation steering", "save capture slot", e,
f"slot {slot_id}'s capture is not kept")
def commit_slot(refinement_key, slot_id, reward):
"""Slot equivalent of commit(): pairs ONE named slot's capture with a rating and appends
it to the same rows log direction() reads, then frees that slot. Slots are independent of
each other and of `pending` -- rating one does not consume or disturb any other slot, so
a whole batch can be rated in any order, any subset, whenever -- the actual point of
a batch. Same return contract as commit(): "recorded" | "no_pending" | "no_key"."""
if not refinement_key:
return "no_key"
data = _load(refinement_key)
captured = data["slots"].pop(str(slot_id), None)
if captured is None:
try:
_save(refinement_key, data)
except OSError:
pass
return "no_pending"
try:
data["rows"].append({"desc": captured, "weight": float(reward)})
except (TypeError, ValueError):
try:
_save(refinement_key, data)
except OSError:
pass
return "no_pending"
try:
_save(refinement_key, data)
except OSError as e:
_log().failed("H3 representation steering", "commit slot", e, "rating not recorded")
return "no_pending"
return "recorded"
def discard_slot(refinement_key, slot_id):
"""Drop a slot's capture without rating it (Skip) -- costs nothing, frees the state."""
if not refinement_key:
return
data = _load(refinement_key)
if data["slots"].pop(str(slot_id), None) is not None:
try:
_save(refinement_key, data)
except OSError:
pass
def clear_all(refinement_key, kind="repr_steer"):
if not refinement_key:
return
try:
os.remove(state_path(refinement_key, kind))
except FileNotFoundError:
pass
def direction(refinement_key, block=None, kind="repr_steer"):
"""-> (unit_vector, n_positive, n_negative), or (None, n_positive, n_negative) if either
side is under MIN_PER_GROUP. One Awful and one Perfect is not a direction, it is two
points -- doesn't matter how many barely-positive rows sit between them.
Recency-weighted mean(liked) - mean(disliked): row i of n carries RECENCY_DECAY**(n-1-i),
so the newest row votes 1.0 and older rows fade. Liked/disliked by weight SIGN only --
not weighted by magnitude. Weighting by magnitude (a 9/10 pulling harder than a 6/10) is the statistically
better estimator, but only once there is enough data for a weakly-rated row's noise to
average out; at the handful of ratings this actually runs on, a mediocre "6" still gets a
vote and dilutes the direction with an ambiguous case instead of being excluded by it. A
binary split spends what little data exists on the clearest examples only. Revisit
weighting once a key has real volume, not before -- see the 2026-09-04 dev session.
Raw descriptors, not per-vector normalised: a hidden-state row's magnitude carries real
content, and normalising it away is the trajectory_probe failure this deliberately avoids.
`block` defaults to DEFAULT_BLOCK -- the one that actually steers. Rows captured before
that block was in CANDIDATE_BLOCKS (or missing it for any other reason) are skipped, not
treated as zero."""
block = DEFAULT_BLOCK if block is None else int(block)
data = _load(refinement_key, kind)
rows = [r for r in data["rows"]
if isinstance(r, dict) and "weight" in r and isinstance(r.get("desc"), dict)
and block in r["desc"]]
# Age counts every recorded rating, liked or not: the decay is "per rating since".
aged = [(r["desc"][block], r["weight"], RECENCY_DECAY ** (len(rows) - 1 - i))
for i, r in enumerate(rows)]
liked = [(d, a) for d, w, a in aged if w > 0]
disliked = [(d, a) for d, w, a in aged if w < 0]
n_pos, n_neg = len(liked), len(disliked)
if n_pos < MIN_PER_GROUP or n_neg < MIN_PER_GROUP:
return None, n_pos, n_neg
def _wmean(group):
ds = torch.stack([d for d, _ in group]).float()
a = torch.tensor([a for _, a in group], dtype=ds.dtype, device=ds.device)
return (ds * a.view(-1, *([1] * (ds.dim() - 1)))).sum(dim=0) / a.sum()
diff = _wmean(liked) - _wmean(disliked)
norm = diff.norm()
if not torch.isfinite(norm) or norm <= 1e-8:
return None, n_pos, n_neg
return diff / norm, n_pos, n_neg
def _trajectory_probe():
try:
from . import trajectory_probe as tp
except ImportError:
import trajectory_probe as tp
return tp
def block_sweep(refinement_key, trials=2000):
"""-> {block: {"separation":..., "p_value":..., "noise_floor":..., "n":...}}, ranked by
which of CANDIDATE_BLOCKS actually separates liked from disliked in YOUR rated data --
reusing trajectory_probe's own permutation test (the one that correctly found real signal
in the early-schedule work this session, after the centring fix) instead of trusting
REINS' 50%-depth heuristic or porting a diagnostic built for a different question (LTXAV's
identity-block search measured cross-scene consistency, not liked-vs-disliged separation).
No cross-prompt grouping -- it was tried (prompt_hash on each row) and dropped: the
R2V-wired conditioning this runs on never produced a real hash, so the guard was
permanently inert, and fixing it was declined as not worth the continued attention. Rows
with weight == 0 (a rating exactly at the scale's neutral midpoint) are dropped -- neither
liked nor disliked."""
tp = _trajectory_probe()
data = _load(refinement_key)
rows = [r for r in data["rows"]
if isinstance(r, dict) and isinstance(r.get("desc"), dict)
and r.get("weight", 0) != 0]
out = {}
for block in CANDIDATE_BLOCKS:
have = [r for r in rows if block in r["desc"]]
if len(have) < 2 * MIN_PER_GROUP:
continue
descriptors = [r["desc"][block] for r in have]
labels = [r["weight"] > 0 for r in have]
result = tp.permutation_test(descriptors, labels, trials=trials)
if result is not None:
out[block] = result
return out