Files
olivv-cs-ComfyUI-FunPack/block_influence.py
T
digital-garbage be5579f1f9 fix: rank blocks by share of movement, not by a ratio depth decides
Live data showed block 1 at 6.01 and block 0 at 1.83 against ~0.2-0.35
everywhere else, with block 1's liked/disliked gap (+5.82) as large as
its whole measurement. That is not "the first two blocks do 30x the
work" — ||f_i||/||x_i|| divides by a residual stream that GROWS with
depth, so early blocks score high because the denominator is barely
formed. Ranking on it just sorts the earliest blocks to the top, and a
high-variance early block fakes a large taste correlation at low n.

The hook now stores both norms instead of their ratio, and profile()
adds `share` — each block's raw ||f_i|| over every block's, one
denominator for the whole stack. The panel ranks and displays that
("2.3% of the movement"). The ratio is kept, since it is still the right
answer to "how much did this block move the stream relative to what the
stream was".

Checked against data shaped like the report: ratio ranks 0,1,2 (all
artifact), share ranks the blocks that actually move more.
2026-09-04 18:10:55 +03:00

290 lines
12 KiB
Python

"""Per-block influence probe for MiniMax H3.
Answers one thing nothing else here measures: how much does each of H3's blocks actually
move the VIDEO rows of the hidden stream, and does that profile differ between generations
you rated liked and ones you rated disliked.
Each block of a residual transformer contributes `x_{i+1} = x_i + f_i(x_i)`. This records
||f_i(x_i)|| / ||x_i|| on video rows only, averaged over every sampling step of a run. Free
in the sense that matters: two norms per block per step, no extra forward pass, no second
model call. Row-subsampled (see samplers._install_block_influence) so the cost does not
scale with H3's 17k+ sequence length.
WHAT THIS PROVES, AND WHAT IT DOES NOT. A large residual delta means the block moved the
stream a lot at that point -- NOT that the movement survived to the final video. The blocks
behind it, and the final layer, can normalise an early push away entirely, and a small late
delta can reach the output untouched. The causal version of this question is ablation: zero
block i, re-run, measure how far the output latent moved. That is ~50 single-step forwards
per probe -- an offline calibration pass, cached per model+LoRA stack (a LoRA changes the
weights, so it changes the map), not something to run per generation. This module is the
free correlational map, and its job is to answer the question that gates the expensive one:
is the depth profile flat, or is it structured? A flat profile means rating-driven per-block
weighting has nothing to grip.
PRIOR ART IN THIS REPO, read before trusting a positive result: per-block residual steering
from ratings was built and REMOVED (b7ed4f9) -- block activity was found to vary in the 4th
decimal with no extractable rating signal. That attempt had no per-step resolution, which
its own removal note named as the missing piece; this one keeps every step separate before
averaging. So this either reproduces that null definitively, or it doesn't. Both outcomes
are worth having, and a null here is the cheaper answer.
H3 is 50 dense uniform blocks with no MoE and no router (see the H3 intervention map), so
there is nothing to REROUTE -- the only levers a block exposes are skip, scale, and add.
This measures which of those would be worth aiming at.
"""
from __future__ import annotations
import os
import torch
# Match h3_repr_steering's floor: a liked-vs-disliked profile difference needs 2+ rated runs
# on each side before it is a difference rather than a pair of points.
MIN_PER_GROUP = 2
_ENV_SWITCH = "FUNPACK_BLOCK_INFLUENCE"
_ENABLED_FILE = "enabled"
def _switch_dir():
base_dir = os.path.dirname(os.path.abspath(__file__))
return os.path.join(base_dir, "refinements", "block_influence")
def _switch_path():
return os.path.join(_switch_dir(), _ENABLED_FILE)
def collection_enabled():
"""True when the probe should record. OFF by default -- this is research data, opted
into from Settings > Refinement & Taste, not something every generation pays for.
Same two-layer switch trajectory_probe uses, for the same reason: the environment
variable is live (the sampler runs in this process, so the toggle reaches the very next
generation with nothing to restart) and wins when set, while the on-disk copy means a
restarted ComfyUI -- a fresh rental -- comes back recording instead of having quietly
stopped mid-measurement."""
raw = os.environ.get(_ENV_SWITCH, "").strip().lower()
if raw:
return raw in ("1", "true", "yes", "on")
try:
with open(_switch_path(), "r", encoding="utf-8") as fh:
return fh.read().strip() == "1"
except (OSError, ValueError):
return False
def set_collection_enabled(on):
"""Set the switch for this process AND the next one. -> the new state."""
on = bool(on)
os.environ[_ENV_SWITCH] = "1" if on else "0"
try:
os.makedirs(_switch_dir(), exist_ok=True)
tmp = _switch_path() + ".tmp"
with open(tmp, "w", encoding="utf-8") as fh:
fh.write("1" if on else "0")
os.replace(tmp, _switch_path())
except OSError as e:
_log().failed("H3 block influence", "switch save", e,
"recording is set for this session only and reverts after a restart")
return on
def _log():
try:
from . import funpack_log as fl
except ImportError:
import funpack_log as fl
return fl
def state_path(refinement_key):
try:
from .conditioning import refinement_state_path
except ImportError:
from conditioning import refinement_state_path
return refinement_state_path(refinement_key, "block_influence", prefix="refine_v2",
extension="pt")
def _load(refinement_key):
path = state_path(refinement_key)
if not os.path.exists(path):
return {"rows": [], "pending": None}
try:
data = torch.load(path, map_location="cpu", weights_only=True)
except Exception: # noqa: BLE001
return {"rows": [], "pending": None}
if not isinstance(data, dict):
return {"rows": [], "pending": None}
data.setdefault("rows", [])
data.setdefault("pending", None)
data.setdefault("pending_novelty", None)
data.setdefault("pending_raw", None)
return data
def _save(refinement_key, data):
path = state_path(refinement_key)
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, profile, novelty=None, raw=None):
"""Store this run's per-block profile as the pending candidate the next rating scores.
`profile` is {block_index: mean relative delta}; `novelty` is the optional companion
{block_index: mean cosine with the PREVIOUS block's delta}. Overwritten every run, same
reason h3_repr_steering.save_pending overwrites: a pending capture must never survive to
be paired with a LATER run's rating."""
if not refinement_key or not profile:
return
data = _load(refinement_key)
data["pending"] = {int(b): float(v) for b, v in profile.items()}
data["pending_novelty"] = ({int(b): float(v) for b, v in novelty.items()}
if novelty else None)
# Raw ||f_i|| alongside the ratio: see `share` in profile() for why the ratio alone is
# not comparable across depth.
data["pending_raw"] = ({int(b): float(v) for b, v in raw.items()} if raw else None)
try:
_save(refinement_key, data)
except OSError as e:
_log().failed("H3 block influence", "save pending", e, "this run's profile is not kept")
def commit(refinement_key, reward):
"""Pair the pending profile with the rating that scored its run.
-> "recorded" | "no_pending" | "no_key", same contract h3_repr_steering.commit uses so
the caller can tell a real commit from a reward that had nothing to attach to."""
if not refinement_key:
return "no_key"
data = _load(refinement_key)
pending = data.get("pending")
pending_novelty = data.get("pending_novelty")
pending_raw = data.get("pending_raw")
data["pending"] = None
data["pending_novelty"] = None
data["pending_raw"] = None
if pending is None:
try:
_save(refinement_key, data)
except OSError:
pass
return "no_pending"
try:
data["rows"].append({"profile": pending, "novelty": pending_novelty or {},
"raw": pending_raw or {}, "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 block influence", "commit", e, "rating not recorded")
return "no_pending"
return "recorded"
def clear_all(refinement_key):
if not refinement_key:
return
try:
os.remove(state_path(refinement_key))
except FileNotFoundError:
pass
def _blocks_in(rows):
blocks = set()
for r in rows:
prof = r.get("profile")
if isinstance(prof, dict):
blocks.update(int(b) for b in prof)
return sorted(blocks)
def profile(refinement_key):
"""-> {"overall": {block: mean}, "liked": {...}|None, "disliked": {...}|None,
"difference": {block: liked-disliked}|None, "n_liked": int, "n_disliked": int,
"flatness": float|None}
`overall` is the depth profile itself -- where this model does its work, regardless of
ratings. `novelty` is the per-block cosine with the PREVIOUS block's delta, and
`mean_novelty` its average: ~1 means blocks mostly amplify their predecessor (little new
per block), ~0 means each adds an orthogonal component nothing before it added, negative
means blocks partly UNDO each other. Magnitude cannot tell those apart, which is why it
is recorded alongside rather than instead.
`share` is each block's RAW movement ||f_i|| as a fraction of every block's, which is the
number to rank by. `overall` (the ratio ||f_i||/||x_i||) is not comparable across depth:
the residual stream grows as it goes, so early blocks score high because the denominator
is barely formed rather than because they move the picture more. `difference` is the rating-driven signal: blocks whose activity runs higher on
runs you liked. `flatness` is the spread of `overall` as a fraction of its own mean
(coefficient of variation): near 0 means every block moves the stream by the same amount
and there is nothing to aim at; a large value means the depth profile is structured.
Rows missing a block are skipped for that block rather than counted as zero -- a run that
hooked fewer blocks must not drag a block's mean down."""
data = _load(refinement_key)
rows = [r for r in data["rows"]
if isinstance(r, dict) and isinstance(r.get("profile"), dict) and "weight" in r]
blocks = _blocks_in(rows)
if not rows or not blocks:
return {"overall": {}, "liked": None, "disliked": None, "difference": None,
"n_liked": 0, "n_disliked": 0, "flatness": None,
"novelty": {}, "mean_novelty": None, "share": {}, "raw": {}}
def _mean(subset):
out = {}
for b in blocks:
vals = [float(r["profile"][b]) for r in subset if b in r["profile"]]
if vals:
out[b] = sum(vals) / len(vals)
return out
def _mean_novelty(subset):
out = {}
for b in blocks:
vals = [float(r["novelty"][b]) for r in subset
if isinstance(r.get("novelty"), dict) and b in r["novelty"]]
if vals:
out[b] = sum(vals) / len(vals)
return out
def _mean_raw(subset):
out = {}
for b in blocks:
vals = [float(r["raw"][b]) for r in subset
if isinstance(r.get("raw"), dict) and b in r["raw"]]
if vals:
out[b] = sum(vals) / len(vals)
return out
liked_rows = [r for r in rows if r["weight"] > 0]
disliked_rows = [r for r in rows if r["weight"] < 0]
overall = _mean(rows)
novelty = _mean_novelty(rows)
raw = _mean_raw(rows)
total = sum(raw.values())
share = {b: v / total for b, v in raw.items()} if total > 1e-12 else {}
flatness = None
if overall:
vals = torch.tensor(list(overall.values()), dtype=torch.float32)
m = float(vals.mean())
flatness = float(vals.std(unbiased=False) / m) if abs(m) > 1e-12 else None
liked = disliked = difference = None
if len(liked_rows) >= MIN_PER_GROUP and len(disliked_rows) >= MIN_PER_GROUP:
liked, disliked = _mean(liked_rows), _mean(disliked_rows)
difference = {b: liked[b] - disliked[b] for b in liked if b in disliked}
return {"overall": overall, "liked": liked, "disliked": disliked,
"difference": difference, "n_liked": len(liked_rows),
"n_disliked": len(disliked_rows), "flatness": flatness,
"share": share, "raw": raw,
"novelty": novelty, "mean_novelty": (
sum(novelty.values()) / len(novelty) if novelty else None)}