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.
This commit is contained in:
digital-garbage
2026-09-04 17:13:35 +03:00
parent d6981036d0
commit 4aad3d7a09
9 changed files with 332 additions and 34 deletions
+47
View File
@@ -42,6 +42,53 @@ import torch
# 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:
+5 -18
View File
@@ -10218,28 +10218,15 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner):
f"{_rs_reward:+.3f} for rating '{rating_label}' — {_rs_note}")
# Block-influence probe rides the same rating. Measurement only -- it
# steers nothing, so it is committed unconditionally alongside REINS
# rather than behind its own admissibility gate, and stays silent unless
# the sampler actually captured a profile this run.
# rather than behind its own admissibility gate. DELIBERATELY SILENT:
# research data belongs in Settings > Refinement & Taste, where it can be
# read, downloaded and cleared, not in a console the actual run report has
# to share. Failures still speak (funpack_log), findings do not.
try:
from . import block_influence as _bi
except ImportError:
import block_influence as _bi
if _bi.commit(refinement_key, _rs_reward) == "recorded":
_bi_prof = _bi.profile(refinement_key)
_bi_flat = _bi_prof.get("flatness")
_bi_msg = (f"flatness {_bi_flat:.3f}" if _bi_flat is not None
else "flatness n/a")
_bi_diff = _bi_prof.get("difference")
if _bi_diff:
_bi_top = sorted(_bi_diff.items(), key=lambda kv: -abs(kv[1]))[:3]
_bi_msg += " | liked-vs-disliked top: " + ", ".join(
f"block {b} {v:+.4f}" for b, v in _bi_top)
else:
_bi_msg += (f" | no liked/disliked split yet "
f"({_bi_prof.get('n_liked', 0)} liked / "
f"{_bi_prof.get('n_disliked', 0)} disliked, need "
f"{_bi.MIN_PER_GROUP} of each)")
print(f"[FunPackRefiner] H3 block influence: recorded — {_bi_msg}")
_bi.commit(refinement_key, _rs_reward)
# DynaShift latent memory: pair the sampler's pending raw latent with this rating
# — promote into the negative bank on a bad outcome (intrusions: awful /
# wrong_appearance; or the quality-missing family via reward <= threshold — see
-1
View File
@@ -267,7 +267,6 @@ _EDITOR_DEFAULTS = {
"frame_overlap": 16,
"h3_phrase_emphasis": False,
"h3_phrase_variability": 0.0,
"h3_block_influence": False,
"h3_repr_steering": False,
"h3_repr_steering_block": "25",
"h3_repr_steering_strength": 0.05,
+19
View File
@@ -216,6 +216,25 @@
link.remove();
URL.revokeObjectURL(url);
},
// Block-influence probe (Settings ▸ Learning) — per-KEY data, but the collection
// switch itself is global, like the trajectory probe's.
blockInfluenceStatus: (key) => j("GET", API("/block_influence") + `?key=${encodeURIComponent(key || "default")}`),
blockInfluenceSetEnabled: (key, enabled) => j("POST", API("/block_influence"), { key: key || "default", enabled: !!enabled }),
blockInfluenceClear: (key) => j("POST", API("/block_influence/clear"), { key: key || "default" }),
async blockInfluenceExport(key) {
const k = key || "default";
const res = await fetch(API("/block_influence/export") + `?key=${encodeURIComponent(k)}&t=${Date.now()}`, { cache: "no-store" });
if (!res.ok) throw new Error(readApiError(res, null));
const url = URL.createObjectURL(await res.blob());
const link = document.createElement("a");
link.href = url;
link.download = `${k}.block_influence.pt`;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
},
// Served by ComfyUI's own same-origin /view endpoint — no editor route needed.
tempFileUrl: (f) => "/view?" + new URLSearchParams({
filename: f.filename, subfolder: f.subfolder || "", type: "temp", t: String(f.mtime || Date.now()),
+1 -5
View File
@@ -24,7 +24,6 @@
"trajectory_guidance", "trajectory_guidance_strength",
"dynashift", "dynashift_strength", "dynashift_threshold",
"h3_repr_steering", "h3_repr_steering_strength", "h3_repr_steering_block",
"h3_block_influence",
]);
const RATING_GATED_STUDIO = new Set(["reference_injection", "value_guidance", "steer_mode", "absolute_strength"]);
@@ -320,9 +319,6 @@
hint: "Fraction of the block's own hidden-state norm added along the learned direction. Start low and push it deliberately high on a same-seed test to confirm it moves the video at all before trusting a small value." },
{ name: "h3_repr_steering_block", label: "Steer at block", kind: "combo", choices: ["1", "5", "10", "15", "20", "25", "30", "35", "40"], default: "25", dependsOn: "h3_repr_steering",
hint: "TEST-ONLY: which of the block-sweep's candidate blocks actually gets steered, instead of always block 25. Blocks 1-15 are deep-end territory (the anchor/reference rows haven't been read by the network yet at that depth) -- expect this to behave differently, not just 'weaker', than 20-40. The other candidate blocks are still captured read-only either way." },
{ name: "h3_block_influence", label: "Measure block influence (H3)", kind: "bool", default: false,
hint: "Measures only — steers nothing. Records how much each of H3's 50 blocks moves the picture as it forms, and pairs that with the rating you give the run.",
detail: "Answers whether the depth profile is flat (every block contributes about the same, so weighting blocks by rating has nothing to grip) or structured. Reports a flatness number and, once you have 2+ liked and 2+ disliked runs on the key, which blocks run hotter on the ones you liked. A block moving the stream a lot at that depth is not proof it survived to the final video — that needs per-block ablation, an offline pass. Near-free: two norms per block per step, no extra model pass." },
{ name: "identity_transfer_enabled", label: "Best-FaceID compatibility", kind: "bool", default: false,
hint: "Feeds the identity pin image the way Best-FaceID identity LoRAs expect it. Needs an Identity pin set.",
detail: "Replaces Continuity's identity-pin guide with separate, non-rendered reference tokens plus an optional ArcFace projector below. Load the LoRA itself in Models. No effect without an identity pin." },
@@ -512,7 +508,7 @@
const CHAIN_VIEW_KNOBS = {
chain_continuity: ["carry_i2v_guides", "carry_overlap_through_anchor"],
chain_timing: ["frame_overlap", "transition_duration", "use_same_seed", "cut_opening_frames"],
chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "taste_nearest_prompt", "output_guidance", "output_guidance_strength", "trajectory_guidance", "trajectory_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold", "h3_repr_steering", "h3_repr_steering_strength", "h3_repr_steering_block", "h3_block_influence"],
chain_guidance: ["cfg", "embed_guidance", "embed_guidance_source", "embed_guidance_strength", "score_slider", "score_slider_strength", "taste_nearest_prompt", "output_guidance", "output_guidance_strength", "trajectory_guidance", "trajectory_guidance_strength", "dynashift", "dynashift_strength", "dynashift_threshold", "h3_repr_steering", "h3_repr_steering_strength", "h3_repr_steering_block"],
chain_decode: ["decode_noise_scale", "decode_timestep", "decode_tile_size"],
chain_experimental: ["context_windows", "context_window_length", "context_window_overlap", "context_window_schedule", "context_window_fuse", "context_window_freenoise", "context_window_retain_first", "h3_video_detail", "joyai_memory", "joyai_memory_size", "joyai_fix_frames", "joyai_frame_select", "joyai_memory_strength", "joyai_audio_memory", "v2a_grad_scale", "alg_blur_guides", "alg_guide_blur_strength", "alg_guide_blur_sigma_threshold", "bounded_attention_enabled", "identity_transfer_enabled", "source_id", "phase_scale", "id_strength", "arcface_mode", "debug_log"],
};
+113
View File
@@ -400,6 +400,116 @@
// Split the same way the probe already is: cheap status auto-fetched, the actual sweep
// only on explicit request (the "Read" button below).
let reinsState = null, reinsSweep = null, reinsSweepBusy = false, reinsError = "";
let biState = null, biError = "";
// The whole point of the probe in one number. Flatness is the spread of the per-block
// profile as a fraction of its own mean, so it is comparable across models and runs: near
// zero means every block moves the picture by the same amount and there is nothing for
// rating-driven block weighting to aim at.
function biVerdict(state) {
if (!state || state.flatness == null) return "Nothing recorded yet.";
const f = state.flatness;
const shape = f < 0.05 ? "flat — every block contributes about the same, so weighting "
+ "blocks by rating has nothing to grip"
: f < 0.20 ? "slightly uneven — some structure, not much"
: "structured — blocks differ enough to be worth aiming at";
return `Flatness ${f.toFixed(3)} · ${shape}`;
}
function biProfileTable(state) {
const overall = state && state.overall;
if (!overall || !Object.keys(overall).length) return null;
const diff = (state && state.difference) || null;
// Ranked by how much the block moves the picture, not by block number: the question is
// which blocks do the work, and a 50-row list in index order buries that.
const entries = Object.entries(overall)
.map(([b, v]) => [Number(b), v])
.sort((a, b) => b[1] - a[1])
.slice(0, 12);
const tbl = el("div", "sw-rows");
entries.forEach(([block, v]) => {
const d = diff ? diff[String(block)] : null;
const value = d == null ? v.toFixed(4)
: `${v.toFixed(4)} · liked ${d >= 0 ? "+" : ""}${d.toFixed(4)}`;
tbl.append(infoRow(`Block ${block}`, value, d == null ? null : d > 0));
});
return tbl;
}
function blockInfluenceRows(key) {
const box = el("div", "sw-stack");
const paint = () => {
clear(box);
const rows = el("div", "sw-rows");
const runs = biState ? biState.runs : 0;
const row = el("div", "sw-row");
const main = el("div", "sw-row-main");
main.append(el("div", "sw-row-title", "Record which blocks move the picture"));
main.append(el("div", "sw-row-hint",
"Off by default. Recording only — nothing about your generations changes. Each "
+ "rated generation adds one profile."));
row.append(main);
const lbl = el("label", "chk es-toggle");
const cb = el("input");
cb.type = "checkbox";
cb.checked = !!(biState && biState.enabled);
cb.onchange = async () => {
biError = "";
try { biState = await window.MovieEditorAPI.blockInfluenceSetEnabled(key, cb.checked); }
catch (e) { biError = String(e.message || e); }
paint();
};
lbl.append(cb, el("span", null, ""));
row.append(lbl);
rows.append(row);
rows.append(infoRow("Rated generations recorded (this key)",
biState ? `${runs} (${biState.n_liked} liked / ${biState.n_disliked} disliked)`
: "0", runs > 0 ? true : null));
rows.append(actionRow("Save this key's measurement",
"Downloads every recorded profile as one file. A rental gets replaced and "
+ "refinements/ is not in git, so without this the count restarts on the next box.",
"Download", async () => {
biError = "";
try { await window.MovieEditorAPI.blockInfluenceExport(key); }
catch (e) { biError = String(e.message || e); paint(); }
}, { disabled: !runs }));
rows.append(actionRow("Start fresh",
"Throws away every recorded profile for this key. Recording stays on. Cannot be "
+ "undone — download first if you want to keep it.",
"Clear…", async () => {
if (!window.confirm(`Throw away ${runs} recorded profile(s) for '${key}'?\n\nThis `
+ "cannot be undone. Download it first if you want to keep it.")) return;
biError = "";
try { biState = await window.MovieEditorAPI.blockInfluenceClear(key); }
catch (e) { biError = String(e.message || e); }
paint();
}, { disabled: !runs, danger: true }));
box.append(rows);
box.append(el("div", "sw-hint", biVerdict(biState)));
if (biState && biState.difference == null && runs > 0) {
box.append(el("div", "sw-hint",
`Needs ${biState.min_per_group}+ liked and ${biState.min_per_group}+ disliked `
+ "before it can say which blocks run hotter on the ones you liked."));
}
if (biError) box.append(el("div", "sw-hint", biError));
const tbl = biProfileTable(biState);
if (tbl) {
box.append(el("div", "sw-rows-label",
"Busiest blocks — how much each moves the picture as it forms"));
box.append(tbl);
}
};
paint();
if (window.MovieEditorAPI) {
window.MovieEditorAPI.blockInfluenceStatus(key)
.then((s) => { biState = s; paint(); })
.catch(() => {});
}
return box;
}
function reinsSweepTable(sweep) {
const entries = Object.entries(sweep || {}).sort((a, b) => a[1].p_value - b[1].p_value);
@@ -660,6 +770,9 @@
wrap.append(el("div", "sw-rows-label", "H3 representation steering (REINS)"));
wrap.append(reinsRows(st.project?.refinement_key || "default"));
wrap.append(el("div", "sw-rows-label", "Block influence (measurement only)"));
wrap.append(blockInfluenceRows(st.project?.refinement_key || "default"));
wrap.append(el("div", "sw-rows-label", "Danger zone"));
const danger = el("div", "sw-rows");
danger.append(actionRow("Delete refinement key",
+80
View File
@@ -2171,6 +2171,86 @@ if web is not None and PromptServer is not None:
_rs, state = _repr_steer_state(key)
return web.json_response(state)
# ── block-influence probe ────────────────────────────────────────────────
# Research data, so it lives here rather than in the console: readable, downloadable and
# clearable from Settings > Refinement & Taste, with its own collection switch. Same
# shape as the trajectory probe above, for the same reason.
def _block_influence_module():
try:
import block_influence as bi
except ImportError:
from .. import block_influence as bi # type: ignore
return bi
def _block_influence_state(key):
bi = _block_influence_module()
prof = bi.profile(key)
return bi, {
"key": key,
"enabled": bi.collection_enabled(),
"runs": len(bi._load(key).get("rows") or []),
"n_liked": prof["n_liked"],
"n_disliked": prof["n_disliked"],
"min_per_group": bi.MIN_PER_GROUP,
"flatness": prof["flatness"],
# Stringified block ids: JSON object keys are strings anyway, and leaving ints
# here would come back as strings on the client regardless -- being explicit
# keeps the frontend from sorting "10" before "2" by accident.
"overall": {str(b): v for b, v in (prof["overall"] or {}).items()},
"difference": ({str(b): v for b, v in prof["difference"].items()}
if prof["difference"] else None),
}
@routes.get(UI_PREFIX + "/api/block_influence")
async def _block_influence_status(req):
key = str(req.rel_url.query.get("key") or "default").strip() or "default"
try:
_bi, state = _block_influence_state(key)
return web.json_response(state)
except Exception as e:
return web.json_response({"key": key, "enabled": False, "runs": 0,
"n_liked": 0, "n_disliked": 0, "flatness": None,
"overall": {}, "difference": None, "error": str(e)})
@routes.post(UI_PREFIX + "/api/block_influence")
async def _block_influence_toggle(req):
try:
body = await req.json()
except Exception:
body = {}
key = str(body.get("key") or "default").strip() or "default"
_block_influence_module().set_collection_enabled(bool(body.get("enabled")))
_bi, state = _block_influence_state(key)
return web.json_response(state)
@routes.get(UI_PREFIX + "/api/block_influence/export")
async def _block_influence_export(req):
import os
key = str(req.rel_url.query.get("key") or "default").strip() or "default"
bi = _block_influence_module()
path = bi.state_path(key)
if not os.path.exists(path):
return web.json_response({"problems": [f"nothing recorded yet for '{key}'"]},
status=404)
with open(path, "rb") as f:
body = f.read()
return web.Response(
body=body, content_type="application/octet-stream",
headers={"Content-Disposition":
f'attachment; filename="{key}.block_influence.pt"'})
@routes.post(UI_PREFIX + "/api/block_influence/clear")
async def _block_influence_clear(req):
try:
body = await req.json()
except Exception:
body = {}
key = str(body.get("key") or "default").strip() or "default"
_block_influence_module().clear_all(key)
_bi, state = _block_influence_state(key)
return web.json_response(state)
@routes.get(UI_PREFIX + "/api/temp")
async def _temp_list(_req):
return web.json_response({"files": _list_temp_media()})
+12 -10
View File
@@ -2970,10 +2970,6 @@ class FunPackLTXAVSceneChainSampler:
"default": "25",
"tooltip": "TEST-ONLY: which block actually gets steered (the rest of the sweep's candidate blocks are still captured read-only, same as always). direction() is recomputed for whichever block you pick here -- this does not change what block_sweep ranks, only which block's learned direction gets injected into this run.",
}),
"h3_block_influence": ("BOOLEAN", {
"default": False,
"tooltip": "MEASUREMENT ONLY -- steers nothing. Records how much each of H3's 50 blocks moves the video rows of the hidden stream (its residual delta relative to the stream itself), averaged over every step, and pairs that profile with the rating you give the run. Answers the question that gates rating-driven block weighting: is the depth profile flat (every block contributes the same, nothing to aim at) or structured? A large residual delta means the block moved the stream a lot THERE -- not that it survived to the final video; the causal version of that needs per-block ablation. Near-free: two norms per block per step on ~512 strided rows, no extra forward pass.",
}),
# A connection socket, never a widget — safe at the end, and it must stay after
# every widget above (see the widgets_values note at the top of this block).
"second_pass_sigmas": ("SIGMAS", {
@@ -3682,7 +3678,7 @@ class FunPackLTXAVSceneChainSampler:
alg_anchor_sigma_threshold=0.975,
bounded_attention_enabled=False,
h3_repr_steering=False, h3_repr_steering_strength=0.05,
h3_repr_steering_block="25", h3_block_influence=False,
h3_repr_steering_block="25",
refinement_key="",):
if sampler is None:
raise ValueError("sampler input is required.")
@@ -3749,8 +3745,15 @@ class FunPackLTXAVSceneChainSampler:
# as its inner call, so the delta it records is the block's OWN, not one that already
# contains another mechanism's injection.
_influence_capture = [{}]
if h3_block_influence and refinement_key:
model = self._install_block_influence(model, _influence_capture)
_influence_on = False
if refinement_key:
try:
from . import block_influence as _bi_sw
except ImportError:
import block_influence as _bi_sw
_influence_on = _bi_sw.collection_enabled()
if _influence_on:
model = self._install_block_influence(model, _influence_capture)
_repr_capture = [{}]
if h3_repr_steering and refinement_key:
model = self._install_h3_repr_steering(
@@ -3778,7 +3781,7 @@ class FunPackLTXAVSceneChainSampler:
_bi.save_pending(refinement_key, {
b: float(torch.stack(v).mean().item())
for b, v in _influence_capture[0].items() if v})
elif h3_block_influence and refinement_key:
elif _influence_on:
# An empty capture and a genuinely flat profile look identical downstream,
# and this probe exists to tell those apart -- so it says which one happened
# rather than letting a dead hook read as a finding.
@@ -7089,7 +7092,7 @@ class FunPackLTXAVSceneChainSampler:
trajectory_guidance=False, trajectory_guidance_strength=0.02,
dynashift=False, dynashift_strength=0.3, dynashift_threshold=0.6,
h3_repr_steering=False, h3_repr_steering_strength=0.05,
h3_repr_steering_block="25", h3_block_influence=False,
h3_repr_steering_block="25",
alg_guide_blur_strength=2.0, alg_guide_blur_sigma_threshold=0.975,
alg_anchor=False, alg_anchor_strength=2.0, alg_anchor_sigma_threshold=0.975,
identity_transfer_enabled=False, identity_projector="None", source_id=2.0,
@@ -7946,7 +7949,6 @@ class FunPackLTXAVSceneChainSampler:
h3_repr_steering=h3_repr_steering,
h3_repr_steering_strength=h3_repr_steering_strength,
h3_repr_steering_block=h3_repr_steering_block,
h3_block_influence=h3_block_influence,
refinement_key=refinement_key_input,
)
# cut_opening_frames: let the real, untouched i2v anchor condition the scene
+55
View File
@@ -295,3 +295,58 @@ def test_a_broken_hook_never_breaks_sampling():
res = _call(dit[("double_block", 0)], src, src * 3, "not-segments")
assert torch.equal(res["img"], src * 3)
assert capture[0] == {}
# --- the collection switch ------------------------------------------------------
#
# Research data is opt-in from Settings > Refinement & Taste. This switch gates COLLECTION
# only -- it must never be wired to anything that records a rating (see the 2026-09-03
# session: a "pause capture" toggle that silently stopped writing ratings).
@pytest.fixture
def _switch(monkeypatch, tmp_path):
monkeypatch.delenv(bi._ENV_SWITCH, raising=False)
monkeypatch.setattr(bi, "_switch_dir", lambda: str(tmp_path / "sw"))
return tmp_path
def test_collection_is_off_by_default(_switch):
assert bi.collection_enabled() is False
def test_setting_it_on_persists_to_disk_and_to_this_process(_switch, monkeypatch):
assert bi.set_collection_enabled(True) is True
assert bi.collection_enabled() is True
# A restarted process has no env var; the on-disk copy must still say on.
monkeypatch.delenv(bi._ENV_SWITCH, raising=False)
assert bi.collection_enabled() is True
def test_turning_it_off_persists_too(_switch, monkeypatch):
bi.set_collection_enabled(True)
bi.set_collection_enabled(False)
monkeypatch.delenv(bi._ENV_SWITCH, raising=False)
assert bi.collection_enabled() is False
def test_env_var_wins_over_disk(_switch, monkeypatch):
bi.set_collection_enabled(False)
monkeypatch.setenv(bi._ENV_SWITCH, "1")
assert bi.collection_enabled() is True
def test_unwritable_switch_dir_still_sets_this_session(_switch, monkeypatch):
monkeypatch.setattr(bi, "_switch_dir", lambda: "/nonexistent-root/nope")
assert bi.set_collection_enabled(True) is True
assert bi.collection_enabled() is True # env var carries it for this process
def test_the_switch_does_not_gate_recording(_switch, monkeypatch, tmp_path):
"""HARD RULE: this toggle controls whether the PROBE is installed, nothing else. It must
never stand between a rating and the data it belongs to -- save_pending/commit are
unconditional, exactly as h3_repr_steering's are."""
monkeypatch.setattr(bi, "state_path", lambda key: str(tmp_path / f"{key}.pt"))
bi.set_collection_enabled(False)
bi.save_pending("k", {0: 0.5})
assert bi.commit("k", 1.0) == "recorded"
assert bi.profile("k")["overall"][0] == pytest.approx(0.5)