diff --git a/block_influence.py b/block_influence.py index dbc25336..c27ac9f4 100644 --- a/block_influence.py +++ b/block_influence.py @@ -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: diff --git a/conditioning.py b/conditioning.py index 1e6aeba0..5169fd36 100644 --- a/conditioning.py +++ b/conditioning.py @@ -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 diff --git a/movie_editor/backend/settings_card.py b/movie_editor/backend/settings_card.py index 58b938da..0f246a94 100644 --- a/movie_editor/backend/settings_card.py +++ b/movie_editor/backend/settings_card.py @@ -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, diff --git a/movie_editor/frontend/api.js b/movie_editor/frontend/api.js index 8271eda4..cac5870b 100644 --- a/movie_editor/frontend/api.js +++ b/movie_editor/frontend/api.js @@ -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()), diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index ee6dd6a8..8ba09277 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -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"], }; diff --git a/movie_editor/frontend/settings_window.js b/movie_editor/frontend/settings_window.js index 56b71325..2121aab7 100644 --- a/movie_editor/frontend/settings_window.js +++ b/movie_editor/frontend/settings_window.js @@ -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", diff --git a/movie_editor/server.py b/movie_editor/server.py index ff4b8aab..4c22d8d4 100644 --- a/movie_editor/server.py +++ b/movie_editor/server.py @@ -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()}) diff --git a/samplers.py b/samplers.py index 583f83dc..5521e93b 100644 --- a/samplers.py +++ b/samplers.py @@ -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 diff --git a/tests/test_block_influence.py b/tests/test_block_influence.py index 23ab13e8..c3b4c74a 100644 --- a/tests/test_block_influence.py +++ b/tests/test_block_influence.py @@ -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)