Files
olivv-cs-ComfyUI-FunPack/block_influence.py
T
digital-garbage 4aad3d7a09 refactor: block-influence data lives in Settings, not the console
Research data belongs in Settings > Refinement & Taste — readable,
downloadable, clearable, with its own collection switch — not in a run
report that already carries more than anyone but us needs.

- Removed the sampler widget and its Editor knob entirely. Collection is
  driven by a global switch (env var live for this process + on-disk copy
  so a fresh rental comes back recording), the same two-layer mechanism
  trajectory_probe uses.
- Removed the per-rating console readout. Findings go to the panel;
  failures still speak through funpack_log.
- New section in Refinement & Taste: toggle, recorded count, Download,
  Clear, a plain-language flatness verdict, and the busiest blocks ranked
  with their liked-vs-disliked delta.
- Routes: GET/POST /api/block_influence, /export, /clear.

The switch gates whether the PROBE is installed and nothing else —
save_pending/commit stay unconditional, with a test pinning that, since a
toggle that quietly stops recording ratings is a mistake this project has
already made once.

Verified live: routes round-trip, the panel renders a seeded 6-run
profile, and a planted rating-correlated bump at block 31 surfaces as
+0.0107 in the UI. Seed data cleared afterwards.
2026-09-04 17:13:35 +03:00

242 lines
9.6 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)
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):
"""Store this run's per-block profile as the pending candidate the next rating scores.
`profile` is {block_index: mean relative 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()}
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")
data["pending"] = None
if pending is None:
try:
_save(refinement_key, data)
except OSError:
pass
return "no_pending"
try:
data["rows"].append({"profile": pending, "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. `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}
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
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)
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}