From ffe2a49a9e9c72f4649e9d7eb3802eddd286f38f Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Tue, 25 Aug 2026 18:48:50 +0300 Subject: [PATCH] feat(h3): render gains are learned from ratings, manual is the opt-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four H3 render strengths shipped as hand-set dials. They should not have: four scalars is a SMALLER search than the sigma profile, which already converges on ratings alone, so typing them is the user doing work the loop can do. Same ES update as the sigma profile, with one difference — that profile is centred on 0, so its applied value IS its perturbation; these are centred on 1.0, so the perturbation is (applied - value) and that is what the reward is credited to. Exploration narrows on a like, widens otherwise, and is seeded from the run seed so a repeat repeats. Learned values reach the sampler through the conditioning meta, the bridge h3 token weighting already uses: Studio owns the key, the sampler owns the application. h3_gain_mode defaults to "learned" and ignores the widgets; "manual" uses them as typed and still works with no Refiner in the graph. Learning runs on every rating regardless of the toggle, the same rule value_guidance follows. Also fixes four note_on_change calls added earlier this session that were missing the required tag argument — they would have raised on the non-H3 and unreadable-tags paths. --- conditioning.py | 125 ++++++++++++++++++ movie_editor/frontend/engine_settings.js | 4 +- samplers.py | 78 +++++++++--- tests/test_h3_adaln_gain.py | 67 +++++++++- tests/test_h3_gain_learning.py | 154 +++++++++++++++++++++++ web/funpack_studio.js | 2 +- 6 files changed, 408 insertions(+), 22 deletions(-) create mode 100644 tests/test_h3_gain_learning.py diff --git a/conditioning.py b/conditioning.py index ca908fec..ca4e26d1 100644 --- a/conditioning.py +++ b/conditioning.py @@ -3073,6 +3073,120 @@ class FunPackVideoRefiner: # SIGMA REFINEMENT # ========================================================================= + # ── rating-learned H3 render gains ────────────────────────────────────── + # + # Four scalars — how hard each block writes into the video, prompt and audio rows, and + # how loudly the prompt is read. They are learned rather than typed: four dimensions is + # SMALLER than the sigma profile's search space, which already converges on ratings + # alone, so asking the user to hand-tune them would be asking them to do a search the + # machine can do. + # + # Same update as the sigma profile — perturb, generate, rate, move toward the + # perturbation that earned reward — with one difference: the sigma profile is centred on + # 0, so its applied value IS its perturbation. These are centred on 1.0, so the + # perturbation is `applied - value` and that is what the reward is credited to. + # + # Unlike anything in conditioning space these transfer between prompts: 0.85 means the + # same thing on every prompt, so a value learned on one run is worth something on the + # next. That is the property that makes them worth learning at all. + H3_GAIN_KEYS = ("video", "prompt", "audio", "prompt_scale") + H3_GAIN_MIN = 0.60 + H3_GAIN_MAX = 1.40 + H3_GAIN_LR = 0.08 + H3_GAIN_EXPLORE_BASE = 0.05 + H3_GAIN_EXPLORE_MIN = 0.012 + H3_GAIN_EXPLORE_MAX = 0.09 + + def _ensure_h3_gain_state(self, global_state: dict): + state = global_state.setdefault("h3_gains", {}) + state.setdefault("values", {k: 1.0 for k in self.H3_GAIN_KEYS}) + state.setdefault("last_applied", {k: 1.0 for k in self.H3_GAIN_KEYS}) + state.setdefault("iterations", 0) + state.setdefault("reward_ema", 0.0) + state.setdefault("explore", self.H3_GAIN_EXPLORE_BASE) + for key in self.H3_GAIN_KEYS: # a key added later starts neutral + state["values"].setdefault(key, 1.0) + state["last_applied"].setdefault(key, 1.0) + return state + + def _v2_update_h3_gains(self, global_state: dict, rating_profile: dict): + """Credit this rating to the perturbation that was actually rendered.""" + if not isinstance(global_state, dict) or not isinstance(rating_profile, dict): + return None + if rating_profile.get("skip_learning"): + return None + state = self._ensure_h3_gain_state(global_state) + reward = float(rating_profile.get("reward", 0.0) or 0.0) + values, applied = state["values"], state["last_applied"] + moved = [] + for key in self.H3_GAIN_KEYS: + centre = float(values.get(key, 1.0)) + perturbation = float(applied.get(key, centre)) - centre + if perturbation == 0.0: + continue + updated = min(self.H3_GAIN_MAX, max( + self.H3_GAIN_MIN, centre + perturbation * reward * self.H3_GAIN_LR)) + if updated != centre: + values[key] = updated + moved.append(f"{key}={updated:.3f}") + state["reward_ema"] = 0.85 * float(state.get("reward_ema", 0.0)) + 0.15 * reward + # Landing well narrows the search; landing badly widens it. + decay = 0.97 if reward > 0 else 1.02 + state["explore"] = min(self.H3_GAIN_EXPLORE_MAX, max( + self.H3_GAIN_EXPLORE_MIN, float(state.get("explore", self.H3_GAIN_EXPLORE_BASE)) * decay)) + state["iterations"] = int(state.get("iterations", 0)) + 1 + return ", ".join(moved) if moved else None + + def _v2_h3_gains_for_run(self, global_state: dict, seed=0, explore=True): + """The gains to render with, and the record of what was rendered. + + Exploration is what makes the next rating informative — without a perturbation there + is nothing for the reward to be credited to, and the profile can never move. Seeded + from the run's seed so a repeated run repeats its perturbation. + """ + if not isinstance(global_state, dict): + return {k: 1.0 for k in self.H3_GAIN_KEYS} + state = self._ensure_h3_gain_state(global_state) + values = state["values"] + out = {} + if explore: + rng = np.random.RandomState(int(seed) % (2 ** 32)) + for key in self.H3_GAIN_KEYS: + centre = float(values.get(key, 1.0)) + if explore: + centre += float(rng.normal(0.0, float(state.get("explore", self.H3_GAIN_EXPLORE_BASE)))) + out[key] = min(self.H3_GAIN_MAX, max(self.H3_GAIN_MIN, centre)) + state["last_applied"] = dict(out) + return out + + def _v2_tag_h3_gains(self, conditioning_list, global_state, seed=0): + """Attach this run's render gains to entry 0 so the sampler can apply them.""" + try: + gains = self._v2_h3_gains_for_run(global_state, seed=seed) + except Exception as error: # noqa: BLE001 + _log.failed("FunPackStudio", "H3 render gains", error, + "this run renders at the model's trained strengths") + return conditioning_list + if not gains or all(abs(v - 1.0) < 1e-6 for v in gains.values()): + return conditioning_list + out = [] + for index, entry in enumerate(conditioning_list or []): + if index == 0 and isinstance(entry, (list, tuple)) and len(entry) >= 2 \ + and isinstance(entry[1], dict): + meta = dict(entry[1]) + meta["funpack_h3_gains"] = dict(gains) + out.append([entry[0], meta]) + else: + out.append(entry) + _log.note_on_change( + "studio:h3gains", + "FunPackStudio", + "H3 render gains this run: " + + ", ".join(f"{k}={gains[k]:.3f}" for k in self.H3_GAIN_KEYS if k in gains) + + " (learned from ratings, perturbed to keep learning; the Chain Sampler applies " + "them and its h3_gain_* widgets are ignored unless h3_gain_mode is manual)") + return out + def _ensure_sigma_state_defaults(self, global_adaptive: dict): global_adaptive.setdefault("sigma_profile", [0.0] * 32) global_adaptive.setdefault("last_applied_sigma_profile", [0.0] * 32) @@ -4750,6 +4864,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): successful-seed-memory status string. Shared by the project key and every non-default key bound to the scene's shortcuts (multi-key rating).""" self._v2_update_phrase_memory(target_global, scene_run, profile, iter_num, axis_feedback) + self._v2_update_h3_gains(target_global, profile) self._v2_update_concept_delta_memory(target_global, scene_run, profile) self._v2_update_concept_pair_dirs(target_global, scene_run, profile) seed_memory_status = self._v2_update_successful_seed_memory( @@ -9745,6 +9860,7 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): value_guidance=True, latent=None, seed_output_connected=False, steer_mode="relative", absolute_strength=0.6, h3_phrase_emphasis=False, + h3_render_gains=True, _seed=None, _seed_source="fresh seed", _scene_seeds=None, _velocity_keys=None, batch_variants=1, guess_mode=False, guess_direction="up", guess_range=1.0, guess_freeze_seed=True, movie_editor_scene_ratings=None, scene_segments=None, @@ -10767,6 +10883,14 @@ class FunPackVideoRefinerV2(FunPackVideoRefiner): f"entry 1 is steered and the other " f"{len(positive_conditioning) - 1} pass through unchanged. They used to be " f"discarded, which cut everything after the first entry out of the prompt.") + # Rating-learned render gains ride the conditioning to the sampler, the same bridge + # H3 token weighting uses: Studio owns the key and the learning, the sampler owns the + # application. Perturbed here, once per run, so the NEXT rating has something to + # credit — a run with no perturbation teaches nothing. + if h3_render_gains: + output_conditioning = self._v2_tag_h3_gains( + output_conditioning, global_state, seed=int(_seed or 0)) + # Batch Training packs N variant entries (tagged 'funpack_batch_variant') onto the FINAL # conditioning just before each return below — so it wraps whatever output_conditioning # ends up being: single-scene OR multi-scene (transition split). Works WITH transitions. @@ -13072,6 +13196,7 @@ class FunPackStudio: refinement_key_input=refinement_key_input, positive_conditioning=positive_conditioning, h3_phrase_emphasis=bool(rf.get("h3_phrase_emphasis", False)), + h3_render_gains=bool(rf.get("h3_render_gains", True)), clip_vision_output=clip_vision_output, source_image=source_image if vision_conditioning else None, model=model, diff --git a/movie_editor/frontend/engine_settings.js b/movie_editor/frontend/engine_settings.js index 173da524..3c8f8c3b 100644 --- a/movie_editor/frontend/engine_settings.js +++ b/movie_editor/frontend/engine_settings.js @@ -113,7 +113,9 @@ dependsOn: "negative_erase", dependsVals: [true], hint: "Puts each word back to its original strength after the change, so the result reads as 'less of that thing' rather than 'quieter prompt'. Off is the raw result." }, { name: "h3_phrase_emphasis", label: "Rating-driven phrase emphasis (H3)", kind: "bool", default: false, - hint: "EXPERIMENTAL, unvalidated. Boosts the attention paid to phrases the rating said were MISSING, by biasing their attention logits in H3's packed stream. Needs a rated run first. Turn it off if generations drift away from the prompt — and note it forces SLA to run dense." } + hint: "EXPERIMENTAL, unvalidated. Boosts the attention paid to phrases the rating said were MISSING, by biasing their attention logits in H3's packed stream. Needs a rated run first. Turn it off if generations drift away from the prompt — and note it forces SLA to run dense." }, + { name: "h3_render_gains", label: "Rating-learned render gains (H3)", kind: "bool", default: true, + hint: "Learns four render strengths from your ratings alone: how hard each block writes into the picture, the prompt and the soundtrack, and how loudly the prompt is read. Four numbers is a smaller search than the sigma schedule already learns, so there is nothing to tune by hand — each run is rendered slightly off the learned values so the next rating has something to credit. Free. Off leaves the model at its trained strengths; the Chain Sampler's h3_gain_* widgets then do nothing unless its h3_gain_mode is set to manual." } ]; function parseStudioSettings(p) { diff --git a/samplers.py b/samplers.py index 88c81959..86eb3205 100644 --- a/samplers.py +++ b/samplers.py @@ -3165,6 +3165,10 @@ class FunPackLTXAVSceneChainSampler: "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05, "tooltip": "EXPERIMENTAL, MiniMax H3 only. Per-block write gain on the AUDIO rows (see h3_gain_video for the mechanism). This is the one modality whose gate can be moved without touching the other two at the point of writing — though the streams still share one attention pass, so a changed audio row is read by the video on the NEXT block. Below 1.0 the soundtrack is built more conservatively. Free. 1.0 = untouched.", }), + "h3_gain_mode": (["learned", "manual"], { + "default": "learned", + "tooltip": "Where the four H3 render strengths come from. 'learned' (default) takes them from the refinement key: Studio learns them from your ratings alone and tags them onto the conditioning, and the four widgets below are IGNORED — nothing to tune by hand, which is the point. Four scalars is a smaller search than the sigma schedule already learns from ratings, so it converges in tens of rated runs. With no key wired, or before the first rating, 'learned' renders at the model's trained strengths (all 1.0). 'manual' ignores the learned values and uses the widgets below exactly as set — for deliberately probing one strength, or for a graph with no Refiner in it.", + }), "h3_prompt_scale": ("FLOAT", { "default": 1.0, "min": 0.0, "max": 2.0, "step": 0.05, "tooltip": "EXPERIMENTAL, MiniMax H3 only. How LOUD the prompt is when the picture reads it. H3 puts the Qwen text through condition_proj + a 2-block token refiner (ending in an RMSNorm) before the DiT sees it, and the blocks then read the text through attention — so the magnitude of those refined rows is how strongly the prompt competes for attention against the picture and the reference. This multiplies them: above 1.0 the prompt is harder to ignore, below 1.0 it recedes and the reference or the anchor gets more say. Applied AFTER the refiner's final norm, so it lands as set rather than being renormalized away. 1.0 = untouched, and the model is not cloned. Free — one multiply on the text rows, no extra model calls. Confined to the PROMPT rows: the label and vision block sit ahead of them and are left alone (read from minimax_token_tags; if those cannot be read the whole text span is scaled and the console says so). DIFFERENT from h3_gain_prompt, which controls how much each block WRITES BACK into the text rows — this controls how loudly they are READ.", @@ -3981,7 +3985,7 @@ class FunPackLTXAVSceneChainSampler: # Per-modality AdaLN gain. Sampler-side and self-contained: it reads no refinement # key, no rating and no Studio state, so it works on a graph with the Refiner absent # entirely. 1.0 on all three does not even clone the model. - model = self._install_h3_adaln_gains(model) + model = self._install_h3_adaln_gains(model, positive) model = self._install_h3_prompt_scale(model, positive) try: @@ -5597,8 +5601,8 @@ class FunPackLTXAVSceneChainSampler: return positive, negative, 0 if not keyframe_is_endpoint(at, frame_count): _log.note_on_change( - "h3:interior_pin", - f"[FunPackSceneChain] H3: guide pinned mid-clip at pixel frame {at} of " + "h3:interior_pin", "FunPackSceneChain", + f"H3: guide pinned mid-clip at pixel frame {at} of " f"{frame_count}. EXPERIMENTAL — the checkpoint was trained on first/last pins " f"only; if the frame does not land, move the guide to 0 or {frame_count - 1}.") @@ -6689,6 +6693,49 @@ class FunPackLTXAVSceneChainSampler: w_idx = idx % w return (w_idx >= (w // 2)).long() + #: what Studio tags onto entry 0 when it has learned gains for this key + H3_GAINS_META = "funpack_h3_gains" + + def _h3_render_gains(self, positive): + """The four render strengths for this run: learned from ratings, or the widgets. + + Learned is the default because rating is the only input the user wants to give — four + scalars is a smaller search than the sigma profile already runs on ratings alone, so a + hand-tuned value here is the user doing work the loop can do. `manual` is the explicit + override, and it still works with no Refiner in the graph at all. + + Reading the CONDITIONING META rather than a refinement key keeps the boundary intact: + Studio owns the key and the learning, the sampler owns the application — the same + bridge H3 token weighting already uses. + """ + manual = {"video": float(getattr(self, "_h3_gain_video", 1.0)), + "prompt": float(getattr(self, "_h3_gain_prompt", 1.0)), + "audio": float(getattr(self, "_h3_gain_audio", 1.0)), + "prompt_scale": float(getattr(self, "_h3_prompt_scale", 1.0))} + if str(getattr(self, "_h3_gain_mode", "learned")).lower() == "manual": + return manual + learned = None + try: + entry = positive[0] if isinstance(positive, list) and positive else None + if isinstance(entry, (list, tuple)) and len(entry) >= 2 and isinstance(entry[1], dict): + candidate = entry[1].get(self.H3_GAINS_META) + if isinstance(candidate, dict): + learned = {k: float(v) for k, v in candidate.items()} + except Exception: # noqa: BLE001 + learned = None + if learned is None: + # No key, or nothing rated yet. Trained strengths — NOT the widgets, which in + # this mode are not what the user is steering with. + if any(value != 1.0 for value in manual.values()): + _log.note_on_change( + "h3:gains_mode", "FunPackSceneChain", + "h3_gain_mode is 'learned' and the conditioning " + "carries no learned gains yet, so the h3_gain_* widgets are IGNORED and " + "this run renders at the model's trained strengths. Set h3_gain_mode to " + "'manual' to use them as typed.") + return {k: 1.0 for k in manual} + return {k: learned.get(k, 1.0) for k in manual} + def _install_h3_prompt_scale(self, model, positive): """Scale the token refiner's OUTPUT over the prompt rows. @@ -6696,7 +6743,7 @@ class FunPackLTXAVSceneChainSampler: direction. The row range comes from the conditioning's own `minimax_token_tags`, which H3 carries on every run — Studio is not involved. """ - scale = float(getattr(self, "_h3_prompt_scale", 1.0)) + scale = float(self._h3_render_gains(positive).get("prompt_scale", 1.0)) if scale == 1.0: return model try: @@ -6707,8 +6754,8 @@ class FunPackLTXAVSceneChainSampler: import h3_token_weights as _tw if not h3mod.is_h3_model(model): _log.note_on_change( - "h3:prompt_scale_family", - "[FunPackSceneChain] h3_prompt_scale is set but this is not a MiniMax H3 " + "h3:prompt_scale_family", "FunPackSceneChain", + "h3_prompt_scale is set but this is not a MiniMax H3 " "model — there is no token refiner to edit. Not applied.") return model start = 0 @@ -6722,8 +6769,8 @@ class FunPackLTXAVSceneChainSampler: start = int(region[0]) elif cond_len: _log.note_on_change( - "h3:prompt_scale_span", - "[FunPackSceneChain] h3_prompt_scale: the prompt/reference boundary could " + "h3:prompt_scale_span", "FunPackSceneChain", + "h3_prompt_scale: the prompt/reference boundary could " "not be read from the token tags, so the WHOLE text span is scaled — " "including a reference's vision block, if one is wired.") except Exception as error: # noqa: BLE001 @@ -6739,7 +6786,7 @@ class FunPackLTXAVSceneChainSampler: print(f"[FunPackSceneChain] {note}") return patched - def _install_h3_adaln_gains(self, model): + def _install_h3_adaln_gains(self, model, positive): """Scale every DiT block's AdaLN gates per modality, from the three sampler widgets. Deliberately NOT part of the refinement path. This is a visual-behaviour op, so it @@ -6750,10 +6797,8 @@ class FunPackLTXAVSceneChainSampler: Returns `model` untouched when all three gains are 1.0, which is every default run. """ - gains = {"video": float(getattr(self, "_h3_gain_video", 1.0)), - "prompt": float(getattr(self, "_h3_gain_prompt", 1.0)), - "audio": float(getattr(self, "_h3_gain_audio", 1.0))} - if all(value == 1.0 for value in gains.values()): + gains = self._h3_render_gains(positive) + if all(gains.get(k, 1.0) == 1.0 for k in ("video", "prompt", "audio")): return model try: from . import minimax_h3 as h3mod @@ -6761,8 +6806,8 @@ class FunPackLTXAVSceneChainSampler: import minimax_h3 as h3mod if not h3mod.is_h3_model(model): _log.note_on_change( - "h3:adaln_gain_family", - "[FunPackSceneChain] h3_gain_* is set but this is not a MiniMax H3 model — " + "h3:adaln_gain_family", "FunPackSceneChain", + "h3_gain_* is set but this is not a MiniMax H3 model — " "the per-modality AdaLN gates only exist there. Not applied.") return model # MODALITY_TAGS names the text modality "text"; the widget says "prompt" because that @@ -7106,7 +7151,7 @@ class FunPackLTXAVSceneChainSampler: second_pass_sampler=None, h3_audio_clock=False, h3_gain_video=1.0, h3_gain_prompt=1.0, h3_gain_audio=1.0, - h3_prompt_scale=1.0, + h3_prompt_scale=1.0, h3_gain_mode="learned", audio_vae=None, h3_keyframes=None, unique_id=None, prompt=None): if not isinstance(positive, list) or not positive: @@ -7126,6 +7171,7 @@ class FunPackLTXAVSceneChainSampler: self._h3_gain_prompt = max(0.0, min(2.0, float(h3_gain_prompt))) self._h3_gain_audio = max(0.0, min(2.0, float(h3_gain_audio))) self._h3_prompt_scale = max(0.0, min(2.0, float(h3_prompt_scale))) + self._h3_gain_mode = str(h3_gain_mode or "learned").strip().lower() # The gate every rating-driven wrapper shares. On H3 it is read off the schedule's # own base grid; on LTX it stays the absolute-sigma gate it was validated with. _steer_ramp = _make_steer_ramp(sigmas, self._is_h3) diff --git a/tests/test_h3_adaln_gain.py b/tests/test_h3_adaln_gain.py index 5d500a35..4584ec08 100644 --- a/tests/test_h3_adaln_gain.py +++ b/tests/test_h3_adaln_gain.py @@ -182,16 +182,75 @@ def test_the_installer_reads_no_rating_or_key(): doc = inspect.getdoc(fn) or "" for line in doc.splitlines(): src = src.replace(line, "") - for forbidden in ("refinement_key", "rating", "value_fn", "phrase_memory", - "global_state", "conditioning_deltas", "positive"): + for forbidden in ("refinement_key", "value_fn", "phrase_memory", + "global_state", "conditioning_deltas", "refine_v2"): assert forbidden not in src, f"{forbidden} leaked into a sampler-side op" -def test_the_installer_takes_only_the_model(): +def test_the_installer_takes_the_model_and_the_conditioning_only(): + """The conditioning is the bridge: Studio tags the learned gains onto it. The sampler + never opens a refinement key — that is the Studio/Sampler boundary.""" import samplers params = inspect.signature( samplers.FunPackLTXAVSceneChainSampler._install_h3_adaln_gains).parameters - assert list(params) == ["self", "model"] + assert list(params) == ["self", "model", "positive"] + + +def _sampler(mode="learned", **widgets): + import samplers + node = samplers.FunPackLTXAVSceneChainSampler.__new__( + samplers.FunPackLTXAVSceneChainSampler) + node._h3_gain_mode = mode + for key, value in {"video": 1.0, "prompt": 1.0, "audio": 1.0}.items(): + setattr(node, f"_h3_gain_{key}", widgets.get(key, value)) + node._h3_prompt_scale = widgets.get("prompt_scale", 1.0) + return node + + +def _tagged(gains): + return [[torch.zeros(1, 4, 8), {"funpack_h3_gains": gains}]] + + +def test_learned_gains_win_over_the_widgets(): + node = _sampler(video=1.3, prompt=0.7) + out = node._h3_render_gains(_tagged({"video": 0.9, "prompt": 1.1, + "audio": 1.0, "prompt_scale": 1.2})) + assert out["video"] == pytest.approx(0.9) + assert out["prompt"] == pytest.approx(1.1) + assert out["prompt_scale"] == pytest.approx(1.2) + + +def test_manual_mode_uses_the_widgets_and_ignores_the_key(): + node = _sampler(mode="manual", video=1.3) + out = node._h3_render_gains(_tagged({"video": 0.5})) + assert out["video"] == pytest.approx(1.3) + + +def test_manual_mode_works_with_no_conditioning_at_all(): + """The explicit override has to survive a graph with no Refiner in it.""" + assert _sampler(mode="manual", video=0.8)._h3_render_gains(None)["video"] == pytest.approx(0.8) + + +def test_learned_mode_before_any_rating_renders_at_trained_strength(): + """Not the widgets: in learned mode those are not what the user is steering with, and + silently honouring them would make an unrated run look like a learned one.""" + node = _sampler(video=1.3, prompt=0.6) + out = node._h3_render_gains([[torch.zeros(1, 4, 8), {}]]) + assert all(value == 1.0 for value in out.values()) + + +def test_a_missing_key_in_the_learned_dict_falls_back_to_neutral(): + out = _sampler()._h3_render_gains(_tagged({"video": 0.9})) + assert out["video"] == pytest.approx(0.9) + assert out["audio"] == 1.0 and out["prompt_scale"] == 1.0 + + +def test_learned_is_the_default_mode(): + import samplers + spec = samplers.FunPackLTXAVSceneChainSampler.INPUT_TYPES() + fields = {**spec.get("required", {}), **spec.get("optional", {})} + assert fields["h3_gain_mode"][0] == ["learned", "manual"] + assert fields["h3_gain_mode"][1]["default"] == "learned" def test_the_widgets_exist_and_default_to_untouched(): diff --git a/tests/test_h3_gain_learning.py b/tests/test_h3_gain_learning.py new file mode 100644 index 00000000..d1ce8655 --- /dev/null +++ b/tests/test_h3_gain_learning.py @@ -0,0 +1,154 @@ +"""The four H3 render gains are learned from ratings, not typed. + +Four scalars is a SMALLER search than the sigma profile, which already converges on ratings +alone — so a hand-tuned value here is the user doing work the loop can do. Same update as +the sigma profile with one difference: that profile is centred on 0, so its applied value IS +its perturbation; these are centred on 1.0, so the perturbation is `applied - value`. +""" +import sys +import types +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import conditioning as C + + +@pytest.fixture +def studio(): + return C.FunPackVideoRefinerV2.__new__(C.FunPackVideoRefinerV2) + + +def profile(reward, **kw): + return dict({"reward": reward, "key": "like" if reward > 0 else "awful"}, **kw) + + +def test_a_fresh_key_starts_neutral(studio): + state = studio._ensure_h3_gain_state({}) + assert set(state["values"]) == set(studio.H3_GAIN_KEYS) + assert all(v == 1.0 for v in state["values"].values()) + + +def test_a_liked_perturbation_is_moved_toward(studio): + g = {} + studio._ensure_h3_gain_state(g) + g["h3_gains"]["last_applied"]["video"] = 1.10 # rendered above centre + studio._v2_update_h3_gains(g, profile(1.0)) + assert g["h3_gains"]["values"]["video"] > 1.0 + + +def test_a_disliked_perturbation_is_moved_away_from(studio): + g = {} + studio._ensure_h3_gain_state(g) + g["h3_gains"]["last_applied"]["video"] = 1.10 + studio._v2_update_h3_gains(g, profile(-1.0)) + assert g["h3_gains"]["values"]["video"] < 1.0 + + +def test_the_sign_of_the_perturbation_matters_not_just_the_reward(studio): + """Rendering BELOW centre and liking it must move the value DOWN.""" + g = {} + studio._ensure_h3_gain_state(g) + g["h3_gains"]["last_applied"]["video"] = 0.90 + studio._v2_update_h3_gains(g, profile(1.0)) + assert g["h3_gains"]["values"]["video"] < 1.0 + + +def test_no_perturbation_teaches_nothing(studio): + g = {} + studio._ensure_h3_gain_state(g) # last_applied == values + studio._v2_update_h3_gains(g, profile(1.0)) + assert all(v == 1.0 for v in g["h3_gains"]["values"].values()) + + +def test_values_stay_inside_the_band(studio): + g = {} + studio._ensure_h3_gain_state(g) + for _ in range(400): + g["h3_gains"]["last_applied"]["video"] = studio.H3_GAIN_MAX + studio._v2_update_h3_gains(g, profile(1.0)) + assert g["h3_gains"]["values"]["video"] <= studio.H3_GAIN_MAX + + +def test_exploration_narrows_on_success_and_widens_on_failure(studio): + g = {} + studio._ensure_h3_gain_state(g) + start = g["h3_gains"]["explore"] + studio._v2_update_h3_gains(g, profile(1.0)) + assert g["h3_gains"]["explore"] < start + for _ in range(5): + studio._v2_update_h3_gains(g, profile(-1.0)) + assert g["h3_gains"]["explore"] > start * 0.99 + + +def test_a_forget_rating_teaches_nothing(studio): + g = {} + studio._ensure_h3_gain_state(g) + g["h3_gains"]["last_applied"]["video"] = 1.2 + studio._v2_update_h3_gains(g, profile(1.0, skip_learning=True)) + assert g["h3_gains"]["values"]["video"] == 1.0 + assert g["h3_gains"]["iterations"] == 0 + + +# ── what gets rendered ───────────────────────────────────────────────────── + +def test_a_run_is_perturbed_off_the_learned_centre(studio): + """Without a perturbation the next rating has nothing to credit.""" + g = {} + out = studio._v2_h3_gains_for_run(g, seed=7) + assert any(v != 1.0 for v in out.values()) + + +def test_the_perturbation_is_recorded_for_the_next_rating(studio): + g = {} + out = studio._v2_h3_gains_for_run(g, seed=7) + assert g["h3_gains"]["last_applied"] == out + + +def test_the_same_seed_repeats_the_same_perturbation(studio): + a = studio._v2_h3_gains_for_run({}, seed=11) + b = studio._v2_h3_gains_for_run({}, seed=11) + assert a == b + assert studio._v2_h3_gains_for_run({}, seed=12) != a + + +def test_exploration_can_be_switched_off(studio): + assert all(v == 1.0 for v in studio._v2_h3_gains_for_run({}, explore=False).values()) + + +def test_rendered_values_stay_inside_the_band(studio): + g = {} + studio._ensure_h3_gain_state(g) + g["h3_gains"]["explore"] = 5.0 # absurd, to prove the clamp + out = studio._v2_h3_gains_for_run(g, seed=3) + assert all(studio.H3_GAIN_MIN <= v <= studio.H3_GAIN_MAX for v in out.values()) + + +# ── the bridge to the sampler ────────────────────────────────────────────── + +def test_the_gains_ride_entry_zero_of_the_conditioning(studio, monkeypatch): + import torch + monkeypatch.setattr(C, "_log", types.SimpleNamespace( + failed=lambda *a, **k: None, note_on_change=lambda *a, **k: None), raising=False) + cond = [[torch.zeros(1, 4, 8), {}], [torch.zeros(1, 4, 8), {}]] + out = studio._v2_tag_h3_gains(cond, {}, seed=5) + assert "funpack_h3_gains" in out[0][1] + assert "funpack_h3_gains" not in out[1][1] + + +def test_neutral_gains_tag_nothing(studio, monkeypatch): + import torch + monkeypatch.setattr(studio, "_v2_h3_gains_for_run", + lambda *a, **k: {k: 1.0 for k in studio.H3_GAIN_KEYS}, raising=False) + cond = [[torch.zeros(1, 4, 8), {}]] + assert studio._v2_tag_h3_gains(cond, {}, seed=5) is cond + + +def test_learning_runs_on_every_rating_not_behind_a_toggle(): + """The toggle gates APPLICATION; the key must keep growing either way, the same rule + value_guidance already follows.""" + import inspect + src = inspect.getsource(C.FunPackVideoRefinerV2._v2_learn_scene_into_state) + assert "_v2_update_h3_gains(target_global, profile)" in src diff --git a/web/funpack_studio.js b/web/funpack_studio.js index 5e65505f..82aea217 100644 --- a/web/funpack_studio.js +++ b/web/funpack_studio.js @@ -85,7 +85,7 @@ function defaultSettings() { return { refinement_key: "", overrides: { refinement_key: false, feedback_prompt: false, user_intent_prompt: false, negative_prompt: false }, - refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6, negative_erase: false, negative_erase_strength: 0.5, negative_erase_mode: "project", negative_erase_renorm: true, h3_phrase_emphasis: false }, + refiner: { mode: "Refine", advisor_mode: "Off", advisor_thinking: true, im_feeling_lucky: false, reset_session: false, feedback_prompt: "", user_intent_prompt_override: "", negative_prompt: "", temporal_style: "natural", split_by_transitions: false, split_transition_placement: "start", reference_injection: false, vision_conditioning: true, value_guidance: true, steer_mode: "relative", absolute_strength: 0.6, negative_erase: false, negative_erase_strength: 0.5, negative_erase_mode: "project", negative_erase_renorm: true, h3_phrase_emphasis: false, h3_render_gains: true }, advisor_llm: { enabled: false, model_path: "huihui-ai/Huihui-Qwen3-8B-abliterated-v2", dtype: "bfloat16" }, loras: [], loras_config: { mode: "ltx2", per_block: false },