Files
olivv-cs-ComfyUI-FunPack/tests/test_h3_gain_learning.py
T
digital-garbage 54fa8e4b43 feat(h3): region-locked anchors, and the taste direction lands after the refiner
Region lock: a keyframe pin can cover part of a frame. Transparent areas of an
i2v anchor are dropped from the packed sequence entirely — not blanked, not
noised, absent — so the model invents them. Row deletion re-derives the layout
from upstream's own output; two patches share one keep-mask so they cannot
disagree. Surfaced through the anchor's alpha, which needs no new wire and works
in the Editor. Excluded pixels are filled with the kept area's mean before the
VAE so no hard edge bleeds in. EXPERIMENTAL: fl2va saw whole condition frames.

Taste bias: the liked direction Studio already learns is now also added AFTER
H3's token refiner, where it lands as sent instead of being mixed across tokens
and normalized away. Relative to the span's own row norm, so one strength means
the same thing on every prompt. How far to push is a fifth learned gain,
centred on 0 and signed; h3_taste_bias is the manual override. Needs 3 liked
runs.

Gain state generalized to per-key centres, bounds and exploration width.

Tests: 1608 passed (+46). Untested on a rental.
2026-08-25 21:48:28 +03:00

363 lines
15 KiB
Python

"""The H3 render gains are learned from ratings, not typed.
A handful of 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; most of these are centred on 1.0, so the perturbation is
`applied - value`.
`refiner_bias` is the exception on both counts: it is centred on 0.0 (a signed push along the
learned taste direction, where 0 means no push) and it explores a narrower band, so its
exploration width scales down to match or its perturbations would clip against the bounds and
the reward would be credited to a move that never fully happened.
"""
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)
# Not all neutrals are 1.0: refiner_bias is a signed push, so its neutral is 0.0.
assert state["values"] == {k: studio._h3_gain_centre(k) for k in studio.H3_GAIN_KEYS}
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 g["h3_gains"]["values"] == {k: studio._h3_gain_centre(k) for k in studio.H3_GAIN_KEYS}
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 (studio._v2_h3_gains_for_run({}, explore=False)
== {k: studio._h3_gain_centre(k) for k in studio.H3_GAIN_KEYS})
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)
for key, value in out.items():
low, high = studio._h3_gain_bounds(key)
assert low <= value <= high
# ── 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: {key: studio._h3_gain_centre(key)
for key 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
# ── axis-aware credit assignment ───────────────────────────────────────────
#
# A rating carries three per-axis signals in [-1, +1] as well as its scalar reward, and
# they disagree with it in exactly the cases that matter. "Missing concept" is reward
# +0.10 — quality was fine — but concept_signal -1.00. Read as the scalar it nudges every
# gain toward the run that missed the prompt; read per axis it pushes the prompt gains
# away from it and leaves the audio gain out of it.
def test_missing_concept_punishes_the_prompt_gains(studio):
prof = C.RATING_PROFILES["Missing concept"]
assert prof["reward"] > 0 # the scalar says "slightly good"
assert studio._h3_gain_credit("prompt", prof) == pytest.approx(-1.0)
assert studio._h3_gain_credit("prompt_scale", prof) == pytest.approx(-1.0)
def test_missing_concept_does_not_drag_the_video_gain_down_with_it(studio):
prof = C.RATING_PROFILES["Missing concept"]
assert studio._h3_gain_credit("video", prof) > 0 # detail and quality were fine
def test_missing_details_punishes_the_video_gain(studio):
prof = C.RATING_PROFILES["Missing details"]
assert studio._h3_gain_credit("video", prof) < 0
assert studio._h3_gain_credit("prompt", prof) > 0 # the prompt landed
def test_audio_has_no_rated_axis_so_it_uses_the_overall_reward(studio):
for label in ("Perfect", "Awful", "Missing concept"):
prof = C.RATING_PROFILES[label]
assert studio._h3_gain_credit("audio", prof) == pytest.approx(prof["reward"])
def test_perfect_credits_everything_fully(studio):
prof = C.RATING_PROFILES["Perfect"]
for key in studio.H3_GAIN_KEYS:
assert studio._h3_gain_credit(key, prof) == pytest.approx(1.0)
def test_awful_punishes_everything(studio):
prof = C.RATING_PROFILES["Awful"]
for key in studio.H3_GAIN_KEYS:
assert studio._h3_gain_credit(key, prof) < 0
def test_a_profile_with_no_signals_falls_back_to_the_scalar(studio):
assert studio._h3_gain_credit("prompt", {"reward": 0.4}) == pytest.approx(0.4)
def test_a_satisfied_axis_cannot_mask_a_failed_one(studio):
"""Averaging let it: detail -1.00 with quality +0.85 came out at -0.08, so a rating that
said details were missing barely moved the gain that governs detail."""
prof = {"reward": 0.0, "detail_signal": -1.0, "quality_signal": 1.0}
assert studio._h3_gain_credit("video", prof) == pytest.approx(-1.0)
def test_missing_details_now_moves_the_video_gain_decisively(studio):
assert studio._h3_gain_credit("video", C.RATING_PROFILES["Missing details"]) \
== pytest.approx(-1.0)
def test_both_axes_satisfied_still_credits_fully(studio):
prof = {"reward": 1.0, "detail_signal": 1.0, "quality_signal": 1.0}
assert studio._h3_gain_credit("video", prof) == pytest.approx(1.0)
def test_axis_credit_actually_drives_the_update(studio):
"""The end-to-end consequence: one rating moves two gains in OPPOSITE directions."""
g = {}
studio._ensure_h3_gain_state(g)
for key in ("prompt", "video"):
g["h3_gains"]["last_applied"][key] = 1.10 # both rendered above centre
studio._v2_update_h3_gains(g, dict(C.RATING_PROFILES["Missing concept"]))
assert g["h3_gains"]["values"]["prompt"] < 1.0 # prompt missed -> back off
assert g["h3_gains"]["values"]["video"] > 1.0 # picture was fine -> keep
def test_every_gain_key_has_an_axis_entry(studio):
assert set(studio.H3_GAIN_AXES) == set(studio.H3_GAIN_KEYS)
# ── the learned taste direction ────────────────────────────────────────────
#
# `refiner_bias` says HOW FAR to push; this is WHICH WAY. The direction is the same
# `liked_dir` conditioning ascent already steers along — the difference is where it lands.
# Studio hands it over as a unit vector and the sampler decides the magnitude, because the
# magnitude belongs to the refiner's space and Studio cannot see it.
def _tensor(values):
import torch
return torch.tensor(values, dtype=torch.float32)
def _liked(count, direction=(3.0, 4.0)):
return {"liked_dir": {"direction": C.tensor_to_serializable(_tensor(direction)),
"direction_count": count}}
def test_no_direction_before_enough_liked_runs(studio):
"""Two liked runs is a coincidence. The threshold is the one liked_dir's existing
conditioning-space consumers already use — not a new number invented here."""
assert studio.H3_TASTE_DIR_MIN_COUNT == 3
assert studio._h3_taste_direction(_liked(2)) is None
def test_the_direction_arrives_once_the_threshold_is_met(studio):
assert studio._h3_taste_direction(_liked(3)) is not None
def test_the_direction_is_a_unit_vector(studio):
out = studio._h3_taste_direction(_liked(5, (3.0, 4.0)))
assert float(out.norm()) == pytest.approx(1.0)
assert out.tolist() == pytest.approx([0.6, 0.8])
def test_a_zero_direction_is_refused(studio):
"""Dividing by its norm would produce NaN and poison every prompt row."""
assert studio._h3_taste_direction(_liked(9, (0.0, 0.0))) is None
def test_an_empty_state_has_no_direction(studio):
assert studio._h3_taste_direction({}) is None
assert studio._h3_taste_direction(None) is None
def test_the_direction_rides_entry_zero_with_the_gains(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, _liked(4), seed=5)
assert studio.H3_TASTE_DIR_META in out[0][1]
assert studio.H3_TASTE_DIR_META not in out[1][1]
def test_a_direction_alone_is_worth_tagging(studio, monkeypatch):
"""Neutral gains used to mean "tag nothing" — but a direction with a zero push is still
worth carrying, because the sampler's manual mode can supply the push itself."""
import torch
monkeypatch.setattr(C, "_log", types.SimpleNamespace(
failed=lambda *a, **k: None, note_on_change=lambda *a, **k: None), raising=False)
monkeypatch.setattr(studio, "_v2_h3_gains_for_run",
lambda *a, **k: {key: studio._h3_gain_centre(key)
for key in studio.H3_GAIN_KEYS}, raising=False)
cond = [[torch.zeros(1, 4, 8), {}]]
out = studio._v2_tag_h3_gains(cond, _liked(4), seed=5)
assert out is not cond and studio.H3_TASTE_DIR_META in out[0][1]
# ── the bias explores its own band ─────────────────────────────────────────
def test_the_bias_is_centred_on_no_push(studio):
assert studio._h3_gain_centre("refiner_bias") == 0.0
assert studio._h3_gain_centre("video") == 1.0
def test_the_bias_can_be_learned_negative(studio):
"""Pushing AWAY from the liked direction must be reachable, or the loop can only agree."""
low, high = studio._h3_gain_bounds("refiner_bias")
assert low < 0.0 < high
def test_exploration_scales_to_the_band_it_explores(studio):
"""One stored `explore` drives every gain; a narrower band has to shrink it or its
perturbations clip against the bounds and the credit goes to a move that never happened."""
wide = studio._h3_gain_explore("video", 0.05)
narrow = studio._h3_gain_explore("refiner_bias", 0.05)
assert narrow < wide
low, high = studio._h3_gain_bounds("refiner_bias")
assert narrow == pytest.approx(0.05 * (high - low) / (studio.H3_GAIN_MAX - studio.H3_GAIN_MIN))
def test_a_liked_bias_perturbation_is_moved_toward(studio):
g = {}
studio._ensure_h3_gain_state(g)
g["h3_gains"]["last_applied"]["refiner_bias"] = 0.10
studio._v2_update_h3_gains(g, profile(1.0))
assert g["h3_gains"]["values"]["refiner_bias"] > 0.0
def test_a_disliked_bias_perturbation_is_moved_away_from(studio):
g = {}
studio._ensure_h3_gain_state(g)
g["h3_gains"]["last_applied"]["refiner_bias"] = 0.10
studio._v2_update_h3_gains(g, profile(-1.0))
assert g["h3_gains"]["values"]["refiner_bias"] < 0.0
def test_the_bias_stays_inside_its_own_band_not_the_shared_one(studio):
g = {}
studio._ensure_h3_gain_state(g)
low, high = studio._h3_gain_bounds("refiner_bias")
for _ in range(400):
g["h3_gains"]["last_applied"]["refiner_bias"] = high
studio._v2_update_h3_gains(g, profile(1.0))
assert low <= g["h3_gains"]["values"]["refiner_bias"] <= high